comparison roundup/cgi/client.py @ 5220:14d8f61e6ef2

Reimplemented anti-csrf measures by raising exceptions rather than returning booleans. Redoing it using exceptions was the easiest way to return proper xmlrpc fault messages to the clients. Also this code should now properly make values set in the form override values from the database. So no lost work under some circumstances if the csrf requirements are not met. Also this code does a better job of cleaning up old csrf tokens.
author John Rouillard <rouilj@ieee.org>
date Wed, 05 Apr 2017 20:56:08 -0400
parents 44f7e6b958fe
children 8743b7226dc7
comparison
equal deleted inserted replaced
5219:ade4bbc2716d 5220:14d8f61e6ef2
45 45
46 from email.MIMEBase import MIMEBase 46 from email.MIMEBase import MIMEBase
47 from email.MIMEText import MIMEText 47 from email.MIMEText import MIMEText
48 from email.MIMEMultipart import MIMEMultipart 48 from email.MIMEMultipart import MIMEMultipart
49 import roundup.anypy.email_ 49 import roundup.anypy.email_
50 import xmlrpclib
50 51
51 def initialiseSecurity(security): 52 def initialiseSecurity(security):
52 '''Create some Permissions and Roles on the security object 53 '''Create some Permissions and Roles on the security object
53 54
54 This function is directly invoked by security.Security.__init__() 55 This function is directly invoked by security.Security.__init__()
434 self.determine_language() 435 self.determine_language()
435 # Open the database as the correct user. 436 # Open the database as the correct user.
436 self.determine_user() 437 self.determine_user()
437 self.check_anonymous_access() 438 self.check_anonymous_access()
438 439
439 if self.handle_csrf(xmlrpc=True): 440 try:
440 # Call the appropriate XML-RPC method. 441 # coverting from function returning true/false to
442 # raising exceptions
443 # Call csrf with xmlrpc checks enabled.
444 # It will return True if everything is ok,
445 # raises exception on check failure.
446 csrf_ok = self.handle_csrf(xmlrpc=True)
447 except (Unauthorised, UsageError), msg:
448 # report exception back to server
449 exc_type, exc_value, exc_tb = sys.exc_info()
450 output = xmlrpclib.dumps(
451 xmlrpclib.Fault(1, "%s:%s" % (exc_type, exc_value)),
452 allow_none=True)
453 csrf_ok = False # we had an error, failed check
454
455 if csrf_ok == True:
441 handler = xmlrpc.RoundupDispatcher(self.db, 456 handler = xmlrpc.RoundupDispatcher(self.db,
442 self.instance.actions, 457 self.instance.actions,
443 self.translator, 458 self.translator,
444 allow_none=True) 459 allow_none=True)
445 output = handler.dispatch(input) 460 output = handler.dispatch(input)
446 else:
447 raise Unauthorised, self._error_message
448 461
449 self.setHeader("Content-Type", "text/xml") 462 self.setHeader("Content-Type", "text/xml")
450 self.setHeader("Content-Length", str(len(output))) 463 self.setHeader("Content-Length", str(len(output)))
451 self.write(output) 464 self.write(output)
452 465
508 # Roundup's real web interface (not a file being served up) 521 # Roundup's real web interface (not a file being served up)
509 # so do the Anonymous Web Acess check now 522 # so do the Anonymous Web Acess check now
510 self.check_anonymous_access() 523 self.check_anonymous_access()
511 524
512 # check for a valid csrf token identifying the right user 525 # check for a valid csrf token identifying the right user
513 if self.handle_csrf(): 526 csrf_ok = True
527 try:
528 # coverting from function returning true/false to
529 # raising exceptions
530 csrf_ok = self.handle_csrf()
531 except (UsageError, Unauthorised), msg:
532 csrf_ok = False
533 self.form_wins = True
534 self._error_message = msg
535
536 if csrf_ok:
514 # csrf checks pass. Run actions etc. 537 # csrf checks pass. Run actions etc.
515 # possibly handle a form submit action (may change 538 # possibly handle a form submit action (may change
516 # self.classname and self.template, and may also 539 # self.classname and self.template, and may also
517 # append error/ok_messages) 540 # append error/ok_messages)
518 html = self.handle_action() 541 html = self.handle_action()
941 # We have a nonce being used with a method it should 964 # We have a nonce being used with a method it should
942 # not be. If the nonce exists, report to admin so they 965 # not be. If the nonce exists, report to admin so they
943 # can fix the nonce leakage and destroy it. (nonces 966 # can fix the nonce leakage and destroy it. (nonces
944 # used in a get are more exposed than those used in a 967 # used in a get are more exposed than those used in a
945 # post.) Note, I don't attempt to validate here since 968 # post.) Note, I don't attempt to validate here since
946 # existance here is the sign of a failure. If nonce 969 # existence here is the sign of a failure. If nonce
947 # exists try to report the referer header to try to 970 # exists try to report the referer header to try to
948 # find where this comes from so it can be fixed. If 971 # find where this comes from so it can be fixed. If
949 # nonce doesn't exist just ignore it. Maybe we should 972 # nonce doesn't exist just ignore it. Maybe we should
950 # report, but somebody could spam us with a ton of 973 # report, but somebody could spam us with a ton of
951 # invalid keys and fill up the logs. 974 # invalid keys and fill up the logs.
958 logger.error( 981 logger.error(
959 self._("csrf key used with wrong method from: %s"), 982 self._("csrf key used with wrong method from: %s"),
960 referer) 983 referer)
961 otks.destroy(key) 984 otks.destroy(key)
962 self.db.commit() 985 self.db.commit()
986 # do return here. Keys have been obsoleted.
987 # we didn't do a expire cycle of session keys,
988 # but that's ok.
963 return True 989 return True
964 990
965 config=self.instance.config 991 config=self.instance.config
966 user=self.db.getuid() 992 current_user=self.db.getuid()
967 993
968 # the HTTP headers we check. 994 # List HTTP headers we check. Note that the xmlrpc header is
969 # Note that the xmlrpc header is missing. Its enforcement is 995 # missing. Its enforcement is different (yes/required are the
970 # different (yes/required are the same for example) 996 # same for example) so we don't include here.
971 # so we don't include here.
972 header_names = [ 997 header_names = [
973 "ORIGIN", 998 "ORIGIN",
974 "REFERER", 999 "REFERER",
975 "X-FORWARDED-HOST", 1000 "X-FORWARDED-HOST",
976 "HOST" 1001 "HOST"
977 ] 1002 ]
978 1003
979 header_pass = 0 # count of passing header checks 1004 header_pass = 0 # count of passing header checks
980 1005
981 # if any headers are required and missing, report 1006 # If required headers are missing, raise an error
982 # an error
983 for header in header_names: 1007 for header in header_names:
984 if (config["WEB_CSRF_ENFORCE_HEADER_%s"%header] == 'required' 1008 if (config["WEB_CSRF_ENFORCE_HEADER_%s"%header] == 'required'
985 and "HTTP_%s"%header not in self.env): 1009 and "HTTP_%s"%header not in self.env):
986 self.add_error_message(self._("Missing header: %s")%header) 1010 logger.error(self._("csrf header %s required but missing for user%s."), header, current_user)
987 logger.error(self._("csrf header %s required but missing for user%s."), header, user) 1011 raise Unauthorised, self._("Missing header: %s")%header
988 return False
989 1012
990 # if you change these make sure to consider what
991 # happens if header variable exists but is empty.
992 # self.base.find("") returns 0 for example not -1
993 #
994 # self.base always matches: ^https?://hostname 1013 # self.base always matches: ^https?://hostname
995 enforce=config['WEB_CSRF_ENFORCE_HEADER_ORIGIN']
996 if 'HTTP_ORIGIN' in self.env and enforce != "no":
997 origin = self.env['HTTP_ORIGIN']
998 foundat = self.base.find(origin +'/')
999 if foundat != 0:
1000 if enforce in ('required', 'yes'):
1001 self.add_error_message("Invalid Origin %s"%origin)
1002 logger.error(self._("csrf Origin header check failed for user%s. Value=%s"), user, origin)
1003 return False
1004 elif enforce == 'logfailure':
1005 logger.warning(self._("csrf Origin header check failed for user%s. Value=%s"), user, origin)
1006 else:
1007 header_pass += 1
1008
1009 enforce=config['WEB_CSRF_ENFORCE_HEADER_REFERER'] 1014 enforce=config['WEB_CSRF_ENFORCE_HEADER_REFERER']
1010 if 'HTTP_REFERER' in self.env and enforce != "no": 1015 if 'HTTP_REFERER' in self.env and enforce != "no":
1011 referer = self.env['HTTP_REFERER'] 1016 referer = self.env['HTTP_REFERER']
1012 # self.base always has trailing / 1017 # self.base always has trailing /
1013 foundat = referer.find(self.base) 1018 foundat = referer.find(self.base)
1014 if foundat != 0: 1019 if foundat != 0:
1015 if enforce in ('required', 'yes'): 1020 if enforce in ('required', 'yes'):
1016 self.add_error_message(self._("Invalid Referer %s, %s")%(referer,self.base)) 1021 logger.error(self._("csrf Referer header check failed for user%s. Value=%s"), current_user, referer)
1017 logger.error(self._("csrf Referer header check failed for user%s. Value=%s"), user, referer) 1022 raise Unauthorised, self._("Invalid Referer %s, %s")%(referer,self.base)
1018 return False
1019 elif enforce == 'logfailure': 1023 elif enforce == 'logfailure':
1020 logger.warning(self._("csrf Referer header check failed for user%s. Value=%s"), user, referer) 1024 logger.warning(self._("csrf Referer header check failed for user%s. Value=%s"), current_user, referer)
1021 else: 1025 else:
1022 header_pass += 1 1026 header_pass += 1
1023 1027
1024 enforce=config['WEB_CSRF_ENFORCE_HEADER_X-FORWARDED-HOST'] 1028 # if you change these make sure to consider what
1025 if 'HTTP_X-FORWARDED-HOST' in self.env and enforce != "no": 1029 # happens if header variable exists but is empty.
1026 host = self.env['HTTP_X-FORWARDED-HOST'] 1030 # self.base.find("") returns 0 for example not -1
1027 foundat = self.base.find('://' + host + '/') 1031 enforce=config['WEB_CSRF_ENFORCE_HEADER_ORIGIN']
1028 # 4 means self.base has http:/ prefix, 5 means https:/ prefix 1032 if 'HTTP_ORIGIN' in self.env and enforce != "no":
1029 if foundat not in [4, 5]: 1033 origin = self.env['HTTP_ORIGIN']
1034 foundat = self.base.find(origin +'/')
1035 if foundat != 0:
1030 if enforce in ('required', 'yes'): 1036 if enforce in ('required', 'yes'):
1031 self.add_error_message(self._("Invalid X-FORWARDED-HOST %s")%host) 1037 logger.error(self._("csrf Origin header check failed for user%s. Value=%s"), current_user, origin)
1032 logger.error(self._("csrf X-FORWARDED-HOST header check failed for user%s. Value=%s"), user, host) 1038 raise Unauthorised, self._("Invalid Origin %s"%origin)
1033 return False
1034 elif enforce == 'logfailure': 1039 elif enforce == 'logfailure':
1035 logger.warning(self._("csrf X-FORWARDED-HOST header check failed for user%s. Value=%s"), user, host) 1040 logger.warning(self._("csrf Origin header check failed for user%s. Value=%s"), current_user, origin)
1036 else: 1041 else:
1037 header_pass += 1 1042 header_pass += 1
1043
1044 enforce=config['WEB_CSRF_ENFORCE_HEADER_X-FORWARDED-HOST']
1045 if 'HTTP_X-FORWARDED-HOST' in self.env:
1046 if enforce != "no":
1047 host = self.env['HTTP_X-FORWARDED-HOST']
1048 foundat = self.base.find('://' + host + '/')
1049 # 4 means self.base has http:/ prefix, 5 means https:/ prefix
1050 if foundat not in [4, 5]:
1051 if enforce in ('required', 'yes'):
1052 logger.error(self._("csrf X-FORWARDED-HOST header check failed for user%s. Value=%s"), current_user, host)
1053 raise Unauthorised, self._("Invalid X-FORWARDED-HOST %s")%host
1054 elif enforce == 'logfailure':
1055 logger.warning(self._("csrf X-FORWARDED-HOST header check failed for user%s. Value=%s"), current_user, host)
1056 else:
1057 header_pass += 1
1038 else: 1058 else:
1039 # https://seclab.stanford.edu/websec/csrf/csrf.pdf 1059 # https://seclab.stanford.edu/websec/csrf/csrf.pdf
1040 # recommends checking HTTP HOST header as well. 1060 # recommends checking HTTP HOST header as well.
1041 # If there is an X-FORWARDED-HOST header, check 1061 # If there is an X-FORWARDED-HOST header, check
1042 # that only. The proxy setting X-F-H has probably set 1062 # that only. The proxy setting X-F-H has probably set
1047 host = self.env['HTTP_HOST'] 1067 host = self.env['HTTP_HOST']
1048 foundat = self.base.find('://' + host + '/') 1068 foundat = self.base.find('://' + host + '/')
1049 # 4 means http:// prefix, 5 means https:// prefix 1069 # 4 means http:// prefix, 5 means https:// prefix
1050 if foundat not in [4, 5]: 1070 if foundat not in [4, 5]:
1051 if enforce in ('required', 'yes'): 1071 if enforce in ('required', 'yes'):
1052 self.add_error_message(self._("Invalid HOST %s")%host) 1072 logger.error(self._("csrf HOST header check failed for user%s. Value=%s"), current_user, host)
1053 logger.error(self._("csrf HOST header check failed for user%s. Value=%s"), user, host) 1073 raise Unauthorised, self._("Invalid HOST %s")%host
1054 return False
1055 elif enforce == 'logfailure': 1074 elif enforce == 'logfailure':
1056 logger.warning(self._("csrf HOST header check failed for user%s. Value=%s"), user, host) 1075 logger.warning(self._("csrf HOST header check failed for user%s. Value=%s"), current_user, host)
1057 else: 1076 else:
1058 header_pass += 1 1077 header_pass += 1
1059 1078
1060 enforce=config['WEB_CSRF_HEADER_MIN_COUNT'] 1079 enforce=config['WEB_CSRF_HEADER_MIN_COUNT']
1061 if header_pass < enforce: 1080 if header_pass < enforce:
1062 self.add_error_message(self._("Unable to verify sufficient headers"))
1063 logger.error(self._("Csrf: unable to verify sufficient headers")) 1081 logger.error(self._("Csrf: unable to verify sufficient headers"))
1064 return False 1082 raise UsageError, self._("Unable to verify sufficient headers")
1065 1083
1066 enforce=config['WEB_CSRF_ENFORCE_HEADER_X-REQUESTED-WITH'] 1084 enforce=config['WEB_CSRF_ENFORCE_HEADER_X-REQUESTED-WITH']
1067 if xmlrpc: 1085 if xmlrpc:
1068 if enforce in ['required', 'yes']: 1086 if enforce in ['required', 'yes']:
1069 # if we get here we have usually passed at least one 1087 # if we get here we have usually passed at least one
1072 # E.G. X-Requested-With: XMLHttpRequest 1090 # E.G. X-Requested-With: XMLHttpRequest
1073 # Note we do not use CSRF nonces for xmlrpc requests. 1091 # Note we do not use CSRF nonces for xmlrpc requests.
1074 # 1092 #
1075 # see: https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet#Protecting_REST_Services:_Use_of_Custom_Request_Headers 1093 # see: https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet#Protecting_REST_Services:_Use_of_Custom_Request_Headers
1076 if 'HTTP_X-REQUESTED-WITH' not in self.env: 1094 if 'HTTP_X-REQUESTED-WITH' not in self.env:
1077 logger.error(self._("csrf X-REQUESTED-WITH xmlrpc required header check failed for user%s."), user) 1095 logger.error(self._("csrf X-REQUESTED-WITH xmlrpc required header check failed for user%s."), current_user)
1078 raise UsageError, "Required Header Missing" 1096 raise UsageError, self._("Required Header Missing")
1079 return False # unreached
1080 else:
1081 return True
1082 else:
1083 return True
1084
1085 enforce=config['WEB_CSRF_ENFORCE_TOKEN']
1086 if "@csrf" not in self.form:
1087 if enforce == 'required':
1088 self.add_error_message(self._("No csrf token found"))
1089 logger.error(self._("required csrf field missing for user%s"), user)
1090 return False
1091 else:
1092 if enforce == 'logfailure':
1093 # FIXME include url
1094 logger.warning(self._("required csrf field missing for user%s"), user)
1095 return True
1096 1097
1097 # Expire old csrf tokens now so we don't use them. These will 1098 # Expire old csrf tokens now so we don't use them. These will
1098 # be committed after the otks.destroy below. Note that the 1099 # be committed after the otks.destroy below. Note that the
1099 # self.clean_up run as part of determine_user() will run only 1100 # self.clean_up run as part of determine_user() will run only
1100 # once an hour. If we have short lived (e.g. 5 minute) keys 1101 # once an hour. If we have short lived (e.g. 5 minute) keys
1101 # they will live too long if we depend on clean_up. So we do 1102 # they will live too long if we depend on clean_up. So we do
1102 # our own. 1103 # our own.
1103 otks.clean() 1104 otks.clean()
1104 1105
1105 key=self.form['@csrf'].value 1106 if xmlrpc:
1106 uid = otks.get(key, 'uid', default=None) 1107 # Save removal of expired keys from database.
1107 sid = otks.get(key, 'sid', default=None) 1108 self.db.commit()
1108 1109 # Return from here since we have done housekeeping
1109 # The key has been used or compromised. 1110 # and don't use csrf tokens for xmlrpc.
1110 # Delete it to prevent replay. 1111 return True
1111 otks.destroy(key) 1112
1113 # process @csrf tokens past this point.
1114 key=None
1115 nonce_user = None
1116 nonce_session = None
1117
1118 if '@csrf' in self.form:
1119 key=self.form['@csrf'].value
1120
1121 nonce_user = otks.get(key, 'uid', default=None)
1122 nonce_session = otks.get(key, 'sid', default=None)
1123 # The key has been used or compromised.
1124 # Delete it to prevent replay.
1125 otks.destroy(key)
1126
1127 # commit the deletion/expiration of all keys
1112 self.db.commit() 1128 self.db.commit()
1129
1130 enforce=config['WEB_CSRF_ENFORCE_TOKEN']
1131 if key is None: # we do not have an @csrf token
1132 if enforce == 'required':
1133 logger.error(self._("Required csrf field missing for user%s"), current_user)
1134 raise UsageError, self._("Csrf token is missing.")
1135 elif enforce == 'logfailure':
1136 # FIXME include url
1137 logger.warning(self._("csrf field not supplied by user%s"), current_user)
1138 else:
1139 # enforce is either yes or no. Both permit change if token is
1140 # missing
1141 return True
1113 1142
1114 current_session = self.session_api._sid 1143 current_session = self.session_api._sid
1115 1144
1116 ''' 1145 '''
1117 # I think now that LogoutAction redirects to 1146 # I think now that LogoutAction redirects to
1125 # Handle the case where user logs out and tries to 1154 # Handle the case where user logs out and tries to
1126 # log in again in same window. 1155 # log in again in same window.
1127 # The csrf token for the login button is associated 1156 # The csrf token for the login button is associated
1128 # with the prior login, so it will not validate. 1157 # with the prior login, so it will not validate.
1129 # 1158 #
1130 # To bypass error, Verify that uid != user and that 1159 # To bypass error, Verify that nonce_user != user and that
1131 # user is '2' (anonymous) and there is no current 1160 # user is '2' (anonymous) and there is no current
1132 # session key. Validate that the csrf exists 1161 # session key. Validate that the csrf exists
1133 # in the db and uid and sid are not None. 1162 # in the db and nonce_user and nonce_session are not None.
1134 # Also validate that the action is Login. 1163 # Also validate that the action is Login.
1135 # Lastly requre at least one csrf header check to pass. 1164 # Lastly requre at least one csrf header check to pass.
1136 # If all of those work process the login. 1165 # If all of those work process the login.
1137 if user != uid and \ 1166 if current_user != nonce_user and \
1138 user == '2' and \ 1167 current_user == '2' and \
1139 current_session is None and \ 1168 current_session is None and \
1140 uid is not None and \ 1169 nonce_user is not None and \
1141 sid is not None and \ 1170 nonce_session is not None and \
1142 "@action" in self.form and \ 1171 "@action" in self.form and \
1143 self.form["@action"].value == "Login": 1172 self.form["@action"].value == "Login":
1144 if header_pass > 0: 1173 if header_pass > 0:
1145 otks.destroy(key) 1174 otks.destroy(key)
1146 self.db.commit() 1175 self.db.commit()
1147 return True 1176 return True
1148 else: 1177 else:
1149 self.add_error_message("Reload window before logging in.") 1178 self.add_error_message("Reload window before logging in.")
1150 ''' 1179 '''
1151
1152 # validate against user and session 1180 # validate against user and session
1153 if uid != user: 1181 if current_user != nonce_user:
1154 if enforce in ('required', "yes"): 1182 if enforce in ('required', "yes"):
1155 self.add_error_message(self._("Invalid csrf token found: %s")%key) 1183 logger.error(
1156 logger.error(self._("csrf uid mismatch for user%s."), user) 1184 self._("Csrf mismatch user: current user %s != stored user %s, current session, stored session: %s,%s for key %s."),
1157 return False 1185 current_user, nonce_user, current_session, nonce_session, key)
1186 raise UsageError, self._("Invalid csrf token found: %s")%key
1158 elif enforce == 'logfailure': 1187 elif enforce == 'logfailure':
1159 logger.warning(self._("csrf uid mismatch for user%s."), user) 1188 logger.warning(
1160 if current_session != sid: 1189 self._("logged only: Csrf mismatch user: current user %s != stored user %s, current session, stored session: %s,%s for key %s."),
1190 current_user, nonce_user, current_session, nonce_session, key)
1191 if current_session != nonce_session:
1161 if enforce in ('required', "yes"): 1192 if enforce in ('required', "yes"):
1162 self.add_error_message(self._( 1193 logger.error(
1163 "Invalid csrf session found: %s")%key) 1194 self._("Csrf mismatch user: current session %s != stored session %s, current user/stored user is: %s for key %s."),
1164 logger.error(self._("csrf session mismatch for user%s."), user) 1195 current_session, nonce_session, current_user, key)
1165 return False 1196 raise UsageError, self._("Invalid csrf session found: %s")%key
1166 elif enforce == 'logfailure': 1197 elif enforce == 'logfailure':
1167 logger.warning(self._("csrf session mismatch for user%s."), user) 1198 logger.warning(
1199 self._("logged only: Csrf mismatch user: current session %s != stored session %s, current user/stored user is: %s for key %s."),
1200 current_session, nonce_session, current_user, key)
1201 # we are done and the change can occur.
1168 return True 1202 return True
1169 1203
1170 def opendb(self, username): 1204 def opendb(self, username):
1171 """Open the database and set the current user. 1205 """Open the database and set the current user.
1172 1206

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