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

Roundup Issue Tracker: http://roundup-tracker.org/