Skip to content

Commit 4807045

Browse files
committed
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.
1 parent 4d71b85 commit 4807045

File tree

3 files changed

+170
-101
lines changed

3 files changed

+170
-101
lines changed

doc/xmlrpc.txt

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,9 @@ the only CSRF header check you require is the HTTP host header::
140140
'1'
141141

142142
The one below adds Referer and X-Requested-With headers so it can pass
143-
stronger CSRF detection methods. Note if you are using http rather
144-
than https, replace xmlrpclib.SafeTransport with xmlrpclib.Transport::
143+
stronger CSRF detection methods. It also generates a fault message
144+
from the server and reports it. Note if you are using http rather than
145+
https, replace xmlrpclib.SafeTransport with xmlrpclib.Transport::
145146

146147
import xmlrpclib
147148

@@ -169,3 +170,9 @@ than https, replace xmlrpclib.SafeTransport with xmlrpclib.Transport::
169170
print roundup_server.display('user2', 'username')
170171
print roundup_server.display('issue1', 'status')
171172
print roundup_server.filter('user',['1','2','3'],{'username':'demo'})
173+
174+
# this will fail with a fault
175+
try:
176+
print roundup_server.filter('usr',['0','2','3'],{'username':'demo'})
177+
except Exception, msg:
178+
print msg

roundup/cgi/client.py

Lines changed: 131 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
from email.MIMEText import MIMEText
4848
from email.MIMEMultipart import MIMEMultipart
4949
import roundup.anypy.email_
50+
import xmlrpclib
5051

5152
def initialiseSecurity(security):
5253
'''Create some Permissions and Roles on the security object
@@ -436,15 +437,27 @@ def handle_xmlrpc(self):
436437
self.determine_user()
437438
self.check_anonymous_access()
438439

439-
if self.handle_csrf(xmlrpc=True):
440-
# Call the appropriate XML-RPC method.
440+
try:
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:
441456
handler = xmlrpc.RoundupDispatcher(self.db,
442457
self.instance.actions,
443458
self.translator,
444459
allow_none=True)
445460
output = handler.dispatch(input)
446-
else:
447-
raise Unauthorised, self._error_message
448461

449462
self.setHeader("Content-Type", "text/xml")
450463
self.setHeader("Content-Length", str(len(output)))
@@ -510,7 +523,17 @@ def inner_main(self):
510523
self.check_anonymous_access()
511524

512525
# 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:
514537
# csrf checks pass. Run actions etc.
515538
# possibly handle a form submit action (may change
516539
# self.classname and self.template, and may also
@@ -943,7 +966,7 @@ def handle_csrf(self, xmlrpc=False):
943966
# can fix the nonce leakage and destroy it. (nonces
944967
# used in a get are more exposed than those used in a
945968
# 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
947970
# exists try to report the referer header to try to
948971
# find where this comes from so it can be fixed. If
949972
# nonce doesn't exist just ignore it. Maybe we should
@@ -960,15 +983,17 @@ def handle_csrf(self, xmlrpc=False):
960983
referer)
961984
otks.destroy(key)
962985
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.
963989
return True
964990

965991
config=self.instance.config
966-
user=self.db.getuid()
992+
current_user=self.db.getuid()
967993

968-
# the HTTP headers we check.
969-
# Note that the xmlrpc header is missing. Its enforcement is
970-
# different (yes/required are the same for example)
971-
# so we don't include here.
994+
# List HTTP headers we check. Note that the xmlrpc header is
995+
# missing. Its enforcement is different (yes/required are the
996+
# same for example) so we don't include here.
972997
header_names = [
973998
"ORIGIN",
974999
"REFERER",
@@ -978,63 +1003,58 @@ def handle_csrf(self, xmlrpc=False):
9781003

9791004
header_pass = 0 # count of passing header checks
9801005

981-
# if any headers are required and missing, report
982-
# an error
1006+
# If required headers are missing, raise an error
9831007
for header in header_names:
9841008
if (config["WEB_CSRF_ENFORCE_HEADER_%s"%header] == 'required'
9851009
and "HTTP_%s"%header not in self.env):
986-
self.add_error_message(self._("Missing header: %s")%header)
987-
logger.error(self._("csrf header %s required but missing for user%s."), header, user)
988-
return False
1010+
logger.error(self._("csrf header %s required but missing for user%s."), header, current_user)
1011+
raise Unauthorised, self._("Missing header: %s")%header
9891012

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-
#
9941013
# 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-
10091014
enforce=config['WEB_CSRF_ENFORCE_HEADER_REFERER']
10101015
if 'HTTP_REFERER' in self.env and enforce != "no":
10111016
referer = self.env['HTTP_REFERER']
10121017
# self.base always has trailing /
10131018
foundat = referer.find(self.base)
10141019
if foundat != 0:
10151020
if enforce in ('required', 'yes'):
1016-
self.add_error_message(self._("Invalid Referer %s, %s")%(referer,self.base))
1017-
logger.error(self._("csrf Referer header check failed for user%s. Value=%s"), user, referer)
1018-
return False
1021+
logger.error(self._("csrf Referer header check failed for user%s. Value=%s"), current_user, referer)
1022+
raise Unauthorised, self._("Invalid Referer %s, %s")%(referer,self.base)
10191023
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)
10211025
else:
10221026
header_pass += 1
10231027

1024-
enforce=config['WEB_CSRF_ENFORCE_HEADER_X-FORWARDED-HOST']
1025-
if 'HTTP_X-FORWARDED-HOST' in self.env and enforce != "no":
1026-
host = self.env['HTTP_X-FORWARDED-HOST']
1027-
foundat = self.base.find('://' + host + '/')
1028-
# 4 means self.base has http:/ prefix, 5 means https:/ prefix
1029-
if foundat not in [4, 5]:
1028+
# if you change these make sure to consider what
1029+
# happens if header variable exists but is empty.
1030+
# self.base.find("") returns 0 for example not -1
1031+
enforce=config['WEB_CSRF_ENFORCE_HEADER_ORIGIN']
1032+
if 'HTTP_ORIGIN' in self.env and enforce != "no":
1033+
origin = self.env['HTTP_ORIGIN']
1034+
foundat = self.base.find(origin +'/')
1035+
if foundat != 0:
10301036
if enforce in ('required', 'yes'):
1031-
self.add_error_message(self._("Invalid X-FORWARDED-HOST %s")%host)
1032-
logger.error(self._("csrf X-FORWARDED-HOST header check failed for user%s. Value=%s"), user, host)
1033-
return False
1037+
logger.error(self._("csrf Origin header check failed for user%s. Value=%s"), current_user, origin)
1038+
raise Unauthorised, self._("Invalid Origin %s"%origin)
10341039
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)
10361041
else:
10371042
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
10381058
else:
10391059
# https://seclab.stanford.edu/websec/csrf/csrf.pdf
10401060
# recommends checking HTTP HOST header as well.
@@ -1049,19 +1069,17 @@ def handle_csrf(self, xmlrpc=False):
10491069
# 4 means http:// prefix, 5 means https:// prefix
10501070
if foundat not in [4, 5]:
10511071
if enforce in ('required', 'yes'):
1052-
self.add_error_message(self._("Invalid HOST %s")%host)
1053-
logger.error(self._("csrf HOST header check failed for user%s. Value=%s"), user, host)
1054-
return False
1072+
logger.error(self._("csrf HOST header check failed for user%s. Value=%s"), current_user, host)
1073+
raise Unauthorised, self._("Invalid HOST %s")%host
10551074
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)
10571076
else:
10581077
header_pass += 1
10591078

10601079
enforce=config['WEB_CSRF_HEADER_MIN_COUNT']
10611080
if header_pass < enforce:
1062-
self.add_error_message(self._("Unable to verify sufficient headers"))
10631081
logger.error(self._("Csrf: unable to verify sufficient headers"))
1064-
return False
1082+
raise UsageError, self._("Unable to verify sufficient headers")
10651083

10661084
enforce=config['WEB_CSRF_ENFORCE_HEADER_X-REQUESTED-WITH']
10671085
if xmlrpc:
@@ -1074,25 +1092,8 @@ def handle_csrf(self, xmlrpc=False):
10741092
#
10751093
# see: https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet#Protecting_REST_Services:_Use_of_Custom_Request_Headers
10761094
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)
1078-
raise UsageError, "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
1095+
logger.error(self._("csrf X-REQUESTED-WITH xmlrpc required header check failed for user%s."), current_user)
1096+
raise UsageError, self._("Required Header Missing")
10961097

10971098
# Expire old csrf tokens now so we don't use them. These will
10981099
# be committed after the otks.destroy below. Note that the
@@ -1102,15 +1103,43 @@ def handle_csrf(self, xmlrpc=False):
11021103
# our own.
11031104
otks.clean()
11041105

1105-
key=self.form['@csrf'].value
1106-
uid = otks.get(key, 'uid', default=None)
1107-
sid = otks.get(key, 'sid', default=None)
1106+
if xmlrpc:
1107+
# Save removal of expired keys from database.
1108+
self.db.commit()
1109+
# Return from here since we have done housekeeping
1110+
# and don't use csrf tokens for xmlrpc.
1111+
return True
1112+
1113+
# process @csrf tokens past this point.
1114+
key=None
1115+
nonce_user = None
1116+
nonce_session = None
11081117

1109-
# The key has been used or compromised.
1110-
# Delete it to prevent replay.
1111-
otks.destroy(key)
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
11121128
self.db.commit()
11131129

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
1142+
11141143
current_session = self.session_api._sid
11151144

11161145
'''
@@ -1127,18 +1156,18 @@ def handle_csrf(self, xmlrpc=False):
11271156
# The csrf token for the login button is associated
11281157
# with the prior login, so it will not validate.
11291158
#
1130-
# To bypass error, Verify that uid != user and that
1159+
# To bypass error, Verify that nonce_user != user and that
11311160
# user is '2' (anonymous) and there is no current
11321161
# 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.
11341163
# Also validate that the action is Login.
11351164
# Lastly requre at least one csrf header check to pass.
11361165
# If all of those work process the login.
1137-
if user != uid and \
1138-
user == '2' and \
1166+
if current_user != nonce_user and \
1167+
current_user == '2' and \
11391168
current_session is None and \
1140-
uid is not None and \
1141-
sid is not None and \
1169+
nonce_user is not None and \
1170+
nonce_session is not None and \
11421171
"@action" in self.form and \
11431172
self.form["@action"].value == "Login":
11441173
if header_pass > 0:
@@ -1148,23 +1177,28 @@ def handle_csrf(self, xmlrpc=False):
11481177
else:
11491178
self.add_error_message("Reload window before logging in.")
11501179
'''
1151-
11521180
# validate against user and session
1153-
if uid != user:
1181+
if current_user != nonce_user:
11541182
if enforce in ('required', "yes"):
1155-
self.add_error_message(self._("Invalid csrf token found: %s")%key)
1156-
logger.error(self._("csrf uid mismatch for user%s."), user)
1157-
return False
1183+
logger.error(
1184+
self._("Csrf mismatch user: current user %s != stored user %s, current session, stored session: %s,%s for key %s."),
1185+
current_user, nonce_user, current_session, nonce_session, key)
1186+
raise UsageError, self._("Invalid csrf token found: %s")%key
11581187
elif enforce == 'logfailure':
1159-
logger.warning(self._("csrf uid mismatch for user%s."), user)
1160-
if current_session != sid:
1188+
logger.warning(
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:
11611192
if enforce in ('required', "yes"):
1162-
self.add_error_message(self._(
1163-
"Invalid csrf session found: %s")%key)
1164-
logger.error(self._("csrf session mismatch for user%s."), user)
1165-
return False
1193+
logger.error(
1194+
self._("Csrf mismatch user: current session %s != stored session %s, current user/stored user is: %s for key %s."),
1195+
current_session, nonce_session, current_user, key)
1196+
raise UsageError, self._("Invalid csrf session found: %s")%key
11661197
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.
11681202
return True
11691203

11701204
def opendb(self, username):

0 commit comments

Comments
 (0)