Mercurial > p > roundup > code
comparison roundup/cgi/client.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 | 8768a95c9a4f |
| children | 47bd81998ddc |
comparison
equal
deleted
inserted
replaced
| 5200:16a8a3f0772c | 5201:a9ace22e0a2f |
|---|---|
| 1 """WWW request handler (also used in the stand-alone server). | 1 """WWW request handler (also used in the stand-alone server). |
| 2 """ | 2 """ |
| 3 __docformat__ = 'restructuredtext' | 3 __docformat__ = 'restructuredtext' |
| 4 | 4 |
| 5 import logging | |
| 6 logger = logging.getLogger('roundup') | |
| 7 | |
| 5 import base64, binascii, cgi, codecs, mimetypes, os | 8 import base64, binascii, cgi, codecs, mimetypes, os |
| 6 import quopri, random, re, stat, sys, time | 9 import quopri, re, stat, sys, time |
| 7 import socket, errno | 10 import socket, errno, hashlib |
| 8 import email.utils | 11 import email.utils |
| 9 from traceback import format_exc | 12 from traceback import format_exc |
| 13 | |
| 14 try: | |
| 15 # Use the cryptographic source of randomness if available | |
| 16 from random import SystemRandom | |
| 17 random=SystemRandom() | |
| 18 logger.debug("Importing good random generator") | |
| 19 except ImportError: | |
| 20 from random import random | |
| 21 logger.warning("**SystemRandom not available. Using poor random generator") | |
| 10 | 22 |
| 11 try: | 23 try: |
| 12 from OpenSSL.SSL import SysCallError | 24 from OpenSSL.SSL import SysCallError |
| 13 except ImportError: | 25 except ImportError: |
| 14 SysCallError = None | 26 SysCallError = None |
| 218 cgi module | 230 cgi module |
| 219 - "additional_headers" is a dictionary of additional HTTP headers that | 231 - "additional_headers" is a dictionary of additional HTTP headers that |
| 220 should be sent to the client | 232 should be sent to the client |
| 221 - "response_code" is the HTTP response code to send to the client | 233 - "response_code" is the HTTP response code to send to the client |
| 222 - "translator" is TranslationService instance | 234 - "translator" is TranslationService instance |
| 235 - "client-nonce" is a unique value for this client connection. Can be | |
| 236 used as a nonce for CSP headers and to sign javascript code | |
| 237 presented to the browser. This is different from the CSRF nonces | |
| 238 and can not be used for anti-csrf measures. | |
| 223 | 239 |
| 224 During the processing of a request, the following attributes are used: | 240 During the processing of a request, the following attributes are used: |
| 225 | 241 |
| 226 - "db" | 242 - "db" |
| 227 - "_error_message" holds a list of error messages | 243 - "_error_message" holds a list of error messages |
| 238 directly, use add_ok_message and add_error_message, these, by | 254 directly, use add_ok_message and add_error_message, these, by |
| 239 default, escape the message added to avoid XSS security issues. | 255 default, escape the message added to avoid XSS security issues. |
| 240 | 256 |
| 241 User Identification: | 257 User Identification: |
| 242 Users that are absent in session data are anonymous and are logged | 258 Users that are absent in session data are anonymous and are logged |
| 243 in as that user. This typically gives them all Permissions assigned to the | 259 in as that user. This typically gives them all Permissions assigned |
| 244 Anonymous Role. | 260 to the Anonymous Role. |
| 245 | 261 |
| 246 Every user is assigned a session. "session_api" is the interface to work | 262 Every user is assigned a session. "session_api" is the interface |
| 247 with session data. | 263 to work with session data. |
| 248 | 264 |
| 249 Special form variables: | 265 Special form variables: |
| 250 Note that in various places throughout this code, special form | 266 Note that in various places throughout this code, special form |
| 251 variables of the form :<name> are used. The colon (":") part may | 267 variables of the form :<name> are used. The colon (":") part may |
| 252 actually be one of either ":" or "@". | 268 actually be one of either ":" or "@". |
| 318 self.cookie_path = urllib_.urlparse(self.base)[2] | 334 self.cookie_path = urllib_.urlparse(self.base)[2] |
| 319 # cookies to set in http responce | 335 # cookies to set in http responce |
| 320 # {(path, name): (value, expire)} | 336 # {(path, name): (value, expire)} |
| 321 self._cookies = {} | 337 self._cookies = {} |
| 322 | 338 |
| 339 # define a unique nonce. Can be used for Content Security Policy | |
| 340 # nonces for scripts. | |
| 341 self.client_nonce = self._gen_nonce() | |
| 342 | |
| 323 # see if we need to re-parse the environment for the form (eg Zope) | 343 # see if we need to re-parse the environment for the form (eg Zope) |
| 324 if form is None: | 344 if form is None: |
| 325 self.form = cgi.FieldStorage(fp=request.rfile, environ=env) | 345 self.form = cgi.FieldStorage(fp=request.rfile, environ=env) |
| 326 else: | 346 else: |
| 327 self.form = form | 347 self.form = form |
| 352 self.user = None | 372 self.user = None |
| 353 self.userid = None | 373 self.userid = None |
| 354 self.nodeid = None | 374 self.nodeid = None |
| 355 self.classname = None | 375 self.classname = None |
| 356 self.template = None | 376 self.template = None |
| 377 | |
| 378 def _gen_nonce(self): | |
| 379 """ generate a unique nonce """ | |
| 380 n = '%s%s%s'%(random.random(), id(self), time.time() ) | |
| 381 n = hashlib.sha256(n).hexdigest() | |
| 382 return n | |
| 357 | 383 |
| 358 def setTranslator(self, translator=None): | 384 def setTranslator(self, translator=None): |
| 359 """Replace the translation engine | 385 """Replace the translation engine |
| 360 | 386 |
| 361 'translator' | 387 'translator' |
| 407 self.determine_language() | 433 self.determine_language() |
| 408 # Open the database as the correct user. | 434 # Open the database as the correct user. |
| 409 self.determine_user() | 435 self.determine_user() |
| 410 self.check_anonymous_access() | 436 self.check_anonymous_access() |
| 411 | 437 |
| 412 # Call the appropriate XML-RPC method. | 438 if self.handle_csrf(xmlrpc=True): |
| 413 handler = xmlrpc.RoundupDispatcher(self.db, | 439 # Call the appropriate XML-RPC method. |
| 440 handler = xmlrpc.RoundupDispatcher(self.db, | |
| 414 self.instance.actions, | 441 self.instance.actions, |
| 415 self.translator, | 442 self.translator, |
| 416 allow_none=True) | 443 allow_none=True) |
| 417 output = handler.dispatch(input) | 444 output = handler.dispatch(input) |
| 445 else: | |
| 446 raise Unauthorised, self._error_message | |
| 418 | 447 |
| 419 self.setHeader("Content-Type", "text/xml") | 448 self.setHeader("Content-Type", "text/xml") |
| 420 self.setHeader("Content-Length", str(len(output))) | 449 self.setHeader("Content-Length", str(len(output))) |
| 421 self.write(output) | 450 self.write(output) |
| 422 | 451 |
| 477 # if we've made it this far the context is to a bit of | 506 # if we've made it this far the context is to a bit of |
| 478 # Roundup's real web interface (not a file being served up) | 507 # Roundup's real web interface (not a file being served up) |
| 479 # so do the Anonymous Web Acess check now | 508 # so do the Anonymous Web Acess check now |
| 480 self.check_anonymous_access() | 509 self.check_anonymous_access() |
| 481 | 510 |
| 482 # possibly handle a form submit action (may change self.classname | 511 # check for a valid csrf token identifying the right user |
| 483 # and self.template, and may also append error/ok_messages) | 512 if self.handle_csrf(): |
| 484 html = self.handle_action() | 513 # csrf checks pass. Run actions etc. |
| 514 # possibly handle a form submit action (may change | |
| 515 # self.classname and self.template, and may also | |
| 516 # append error/ok_messages) | |
| 517 html = self.handle_action() | |
| 518 else: | |
| 519 html = None | |
| 485 | 520 |
| 486 if html: | 521 if html: |
| 487 self.write_html(html) | 522 self.write_html(html) |
| 488 return | 523 return |
| 489 | 524 |
| 777 login.verifyLogin(username, password) | 812 login.verifyLogin(username, password) |
| 778 except LoginError, err: | 813 except LoginError, err: |
| 779 self.make_user_anonymous() | 814 self.make_user_anonymous() |
| 780 raise | 815 raise |
| 781 user = username | 816 user = username |
| 817 # try to seed with something harder to guess than | |
| 818 # just the time. If random is SystemRandom, | |
| 819 # this is a no-op. | |
| 820 random.seed(password + time.time()) | |
| 782 | 821 |
| 783 # if user was not set by http authorization, try session lookup | 822 # if user was not set by http authorization, try session lookup |
| 784 if not user: | 823 if not user: |
| 785 user = self.session_api.get('user') | 824 user = self.session_api.get('user') |
| 786 if user: | 825 if user: |
| 839 # otherwise for everything else | 878 # otherwise for everything else |
| 840 if self.user == 'anonymous': | 879 if self.user == 'anonymous': |
| 841 if not self.db.security.hasPermission('Web Access', self.userid): | 880 if not self.db.security.hasPermission('Web Access', self.userid): |
| 842 raise Unauthorised(self._("Anonymous users are not " | 881 raise Unauthorised(self._("Anonymous users are not " |
| 843 "allowed to use the web interface")) | 882 "allowed to use the web interface")) |
| 883 | |
| 884 | |
| 885 def handle_csrf(self, xmlrpc=False): | |
| 886 '''Handle csrf token lookup and validate current user and session | |
| 887 | |
| 888 This implements (or tries to implement) the | |
| 889 Session-Dependent Nonce from | |
| 890 https://seclab.stanford.edu/websec/csrf/csrf.pdf. | |
| 891 | |
| 892 Changing this to an HMAC(sessionid,secret) will | |
| 893 remove the need for saving a fair amount of | |
| 894 state on the server (one nonce per form per | |
| 895 page). If you have multiple forms/page this can | |
| 896 lead to abandoned csrf tokens that have to time | |
| 897 out and get cleaned up.But you lose per form | |
| 898 tokens which may be an advantage. Also the HMAC | |
| 899 is constant for the session, so provides more | |
| 900 occasions for it to be exposed. | |
| 901 | |
| 902 This only runs on post (or put and delete for | |
| 903 future use). Nobody should be changing data | |
| 904 with a get. | |
| 905 | |
| 906 A session token lifetime is settable in | |
| 907 config.ini. A future enhancement to the | |
| 908 creation routines should allow for the requester | |
| 909 of the token to set the lifetime.t | |
| 910 | |
| 911 The unique session key and user id is stored | |
| 912 with the token. The token is valid if the stored | |
| 913 values match the current client's userid and | |
| 914 session. | |
| 915 | |
| 916 If a user logs out, the csrf keys are | |
| 917 invalidated since no other connection should | |
| 918 have the same session id. | |
| 919 | |
| 920 At least to start I am reporting anti-csrf to | |
| 921 the user. If it's an attacker who can see the | |
| 922 site, they can see the @csrf fields and can | |
| 923 probably figure out that he needs to supply | |
| 924 valid headers. Or they can just read this code | |
| 925 8-). So hiding it doesn't seem to help but it | |
| 926 does arguably show the enforcement settings, but | |
| 927 given the newness of this code notifying the | |
| 928 user and having them notify the admins for | |
| 929 debugging seems to be an advantage. | |
| 930 | |
| 931 ''' | |
| 932 # Assume: never allow changes via GET | |
| 933 if self.env['REQUEST_METHOD'] not in ['POST', 'PUT', 'DELETE']: | |
| 934 return True | |
| 935 config=self.instance.config | |
| 936 user=self.db.getuid() | |
| 937 | |
| 938 # the HTTP headers we check. | |
| 939 # Note that the xmlrpc header is missing. Its enforcement is | |
| 940 # different (yes/required are the same for example) | |
| 941 # so we don't include here. | |
| 942 header_names = [ | |
| 943 "ORIGIN", | |
| 944 "REFERER", | |
| 945 "X-FORWARDED-HOST", | |
| 946 "HOST" | |
| 947 ] | |
| 948 | |
| 949 header_pass = 0 # count of passing header checks | |
| 950 | |
| 951 # if any headers are required and missing, report | |
| 952 # an error | |
| 953 for header in header_names: | |
| 954 if (config["WEB_CSRF_ENFORCE_HEADER_%s"%header] == 'required' | |
| 955 and "HTTP_%s"%header not in self.env): | |
| 956 self.add_error_message(self._("Missing header: %s")%header) | |
| 957 logger.error(self._("csrf header %s required but missing for user%s."), header, user) | |
| 958 return False | |
| 959 | |
| 960 # if you change these make sure to consider what | |
| 961 # happens if header variable exists but is empty. | |
| 962 # self.base.find("") returns 0 for example not -1 | |
| 963 # | |
| 964 # self.base always matches: ^https?://hostname | |
| 965 enforce=config['WEB_CSRF_ENFORCE_HEADER_ORIGIN'] | |
| 966 if 'HTTP_ORIGIN' in self.env and enforce != "no": | |
| 967 origin = self.env['HTTP_ORIGIN'] | |
| 968 foundat = self.base.find(origin +'/') | |
| 969 if foundat != 0: | |
| 970 if enforce in ('required', 'yes'): | |
| 971 self.add_error_message("Invalid Origin %s"%origin) | |
| 972 logger.error(self._("csrf Origin header check failed for user%s. Value=%s"), user, origin) | |
| 973 return False | |
| 974 elif enforce == 'logfailure': | |
| 975 logger.warning(self._("csrf Origin header check failed for user%s. Value=%s"), user, origin) | |
| 976 else: | |
| 977 header_pass += 1 | |
| 978 | |
| 979 enforce=config['WEB_CSRF_ENFORCE_HEADER_REFERER'] | |
| 980 if 'HTTP_REFERER' in self.env and enforce != "no": | |
| 981 referer = self.env['HTTP_REFERER'] | |
| 982 # self.base always has trailing / | |
| 983 foundat = referer.find(self.base) | |
| 984 if foundat != 0: | |
| 985 if enforce in ('required', 'yes'): | |
| 986 self.add_error_message(self._("Invalid Referer %s, %s")%(referer,self.base)) | |
| 987 logger.error(self._("csrf Referer header check failed for user%s. Value=%s"), user, referer) | |
| 988 return False | |
| 989 elif enforce == 'logfailure': | |
| 990 logger.warning(self._("csrf Referer header check failed for user%s. Value=%s"), user, referer) | |
| 991 else: | |
| 992 header_pass += 1 | |
| 993 | |
| 994 enforce=config['WEB_CSRF_ENFORCE_HEADER_X-FORWARDED-HOST'] | |
| 995 if 'HTTP_X-FORWARDED-HOST' in self.env and enforce != "no": | |
| 996 host = self.env['HTTP_X-FORWARDED-HOST'] | |
| 997 foundat = self.base.find('://' + host + '/') | |
| 998 # 4 means self.base has http:/ prefix, 5 means https:/ prefix | |
| 999 if foundat not in [4, 5]: | |
| 1000 if enforce in ('required', 'yes'): | |
| 1001 self.add_error_message(self._("Invalid X-FORWARDED-HOST %s")%host) | |
| 1002 logger.error(self._("csrf X-FORWARDED-HOST header check failed for user%s. Value=%s"), user, host) | |
| 1003 return False | |
| 1004 elif enforce == 'logfailure': | |
| 1005 logger.warning(self._("csrf X-FORWARDED-HOST header check failed for user%s. Value=%s"), user, host) | |
| 1006 else: | |
| 1007 header_pass += 1 | |
| 1008 else: | |
| 1009 # https://seclab.stanford.edu/websec/csrf/csrf.pdf | |
| 1010 # recommends checking HTTP HOST header as well. | |
| 1011 # If there is an X-FORWARDED-HOST header, check | |
| 1012 # that only. The proxy setting X-F-H has probably set | |
| 1013 # the host header to a local hostname that is | |
| 1014 # internal name of system not name supplied by user. | |
| 1015 enforce=config['WEB_CSRF_ENFORCE_HEADER_HOST'] | |
| 1016 if 'HTTP_HOST' in self.env and enforce != "no": | |
| 1017 host = self.env['HTTP_HOST'] | |
| 1018 foundat = self.base.find('://' + host + '/') | |
| 1019 # 4 means http:// prefix, 5 means https:// prefix | |
| 1020 if foundat not in [4, 5]: | |
| 1021 if enforce in ('required', 'yes'): | |
| 1022 self.add_error_message(self._("Invalid HOST %s")%host) | |
| 1023 logger.error(self._("csrf HOST header check failed for user%s. Value=%s"), user, host) | |
| 1024 return False | |
| 1025 elif enforce == 'logfailure': | |
| 1026 logger.warning(self._("csrf HOST header check failed for user%s. Value=%s"), user, host) | |
| 1027 else: | |
| 1028 header_pass += 1 | |
| 1029 | |
| 1030 enforce=config['WEB_CSRF_HEADER_MIN_COUNT'] | |
| 1031 if header_pass < enforce: | |
| 1032 self.add_error_message(self._("Unable to verify sufficient headers")) | |
| 1033 logger.error(self._("Csrf: unable to verify sufficient headers")) | |
| 1034 return False | |
| 1035 | |
| 1036 enforce=config['WEB_CSRF_ENFORCE_HEADER_X-REQUESTED-WITH'] | |
| 1037 if xmlrpc and (enforce in ['required', 'yes']): | |
| 1038 # if we get here we have usually passed at least one | |
| 1039 # header check. We check for presence of this custom | |
| 1040 # header for xmlrpc calls only. | |
| 1041 # E.G. X-Requested-With: XMLHttpRequest | |
| 1042 # see: https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet#Protecting_REST_Services:_Use_of_Custom_Request_Headers | |
| 1043 if 'HTTP_X-REQUESTED-WITH' not in self.env: | |
| 1044 self.add_error_message("Required Header Missing") | |
| 1045 logger.error(self._("csrf X-REQUESED-WITH xmlrpc required header check failed for user%s."), user) | |
| 1046 return False | |
| 1047 | |
| 1048 otks=self.db.getOTKManager() | |
| 1049 enforce=config['WEB_CSRF_ENFORCE_TOKEN'] | |
| 1050 if "@csrf" not in self.form: | |
| 1051 if enforce == 'required': | |
| 1052 self.add_error_message(self._("No csrf token found")) | |
| 1053 return False | |
| 1054 else: | |
| 1055 if enforce == 'logfailure': | |
| 1056 # FIXME include url | |
| 1057 logger.warning(self._("csrf field missing for user%s"), user) | |
| 1058 return True | |
| 1059 | |
| 1060 key=self.form['@csrf'].value | |
| 1061 uid = otks.get(key, 'uid', default=None) | |
| 1062 sid = otks.get(key, 'sid', default=None) | |
| 1063 current_session = self.session_api._sid | |
| 1064 # validate against user and session | |
| 1065 | |
| 1066 ''' | |
| 1067 # I think now that LogoutAction redirects to | |
| 1068 # self.base ([tracker] web parameter in config.ini), | |
| 1069 # this code is not needed. However I am keeping it | |
| 1070 # around in case it has to come back to life. | |
| 1071 # Delete if this is still around in 3/2018. | |
| 1072 # rouilj 3/2017. | |
| 1073 # | |
| 1074 # Note using this code may cause a CSRF Login vulnerability. | |
| 1075 # Handle the case where user logs out and tries to | |
| 1076 # log in again in same window. | |
| 1077 # The csrf token for the login button is associated | |
| 1078 # with the prior login, so it will not validate. | |
| 1079 # | |
| 1080 # To bypass error, Verify that uid != user and that | |
| 1081 # user is '2' (anonymous) and there is no current | |
| 1082 # session key. Validate that the csrf exists | |
| 1083 # in the db and uid and sid are not None. | |
| 1084 # Also validate that the action is Login. | |
| 1085 # Lastly requre at least one csrf header check to pass. | |
| 1086 # If all of those work process the login. | |
| 1087 if user != uid and \ | |
| 1088 user == '2' and \ | |
| 1089 current_session is None and \ | |
| 1090 uid is not None and \ | |
| 1091 sid is not None and \ | |
| 1092 "@action" in self.form and \ | |
| 1093 self.form["@action"].value == "Login": | |
| 1094 if header_pass > 0: | |
| 1095 otks.destroy(key) | |
| 1096 return True | |
| 1097 else: | |
| 1098 self.add_error_message("Reload window before logging in.") | |
| 1099 ''' | |
| 1100 | |
| 1101 if uid != user: | |
| 1102 if enforce in ('required', "yes"): | |
| 1103 self.add_error_message(self._("Invalid csrf token found: %s")%key) | |
| 1104 otks.destroy(key) | |
| 1105 return False | |
| 1106 elif enforce == 'logfailure': | |
| 1107 logger.warning(self._("csrf uid mismatch for user%s."), user) | |
| 1108 if self.session_api._sid != sid: | |
| 1109 if enforce in ('required', "yes"): | |
| 1110 self.add_error_message(self._( | |
| 1111 "Invalid csrf session found: %s")%key) | |
| 1112 otks.destroy(key) | |
| 1113 return False | |
| 1114 elif enforce == 'logfailure': | |
| 1115 logger.warning(self._("csrf session mismatch for user%s."), user) | |
| 1116 # Remove the key so a previous csrf key can't be replayed. | |
| 1117 otks.destroy(key) | |
| 1118 return True | |
| 844 | 1119 |
| 845 def opendb(self, username): | 1120 def opendb(self, username): |
| 846 """Open the database and set the current user. | 1121 """Open the database and set the current user. |
| 847 | 1122 |
| 848 Opens a database once. On subsequent calls only the user is set on | 1123 Opens a database once. On subsequent calls only the user is set on |
