Skip to content

Commit 404cd8a

Browse files
committed
issue2551205: Add support for specifying valid origins for api: xmlrpc/rest
We now have an allow list to filter the hosts allowed to do api requests. An element of this allow list must match the http ORIGIN header exactly or the rest/xmlrpc CORS request will result in an error. The tracker host is always allowed to do a request.
1 parent a6f91d9 commit 404cd8a

File tree

6 files changed

+222
-22
lines changed

6 files changed

+222
-22
lines changed

CHANGES.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ Fixed:
9191
set to None to any filter() call should not cause a
9292
traceback. It will pretend as though no filter, sort or
9393
group was specified. (John Rouillard)
94+
- issue2551205 - Add support for specifying valid origins
95+
for api: xmlrpc/rest. Allows CORS to work with roundup
96+
backend. (John Rouillard)
9497

9598
Features:
9699

doc/rest.txt

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ Clients should set the header X-REQUESTED-WITH to any value and the
7272
tracker's config.ini should have ``csrf_enforce_header_x-requested-with
7373
= yes`` or ``required``.
7474

75+
If you want to allow Roundup's api to be accessed by an application
76+
that is not hosted at the same origin as Roundup, you must permit
77+
the origin using the ``allowed_api_origins`` setting in
78+
``config.ini``.
79+
7580
Rate Limiting the API
7681
=====================
7782

@@ -211,15 +216,22 @@ require that REST be enabled. These requests do not make any changes
211216
or get any information from the database. As a result they are
212217
available to the anonymous user and any authenticated user. The user
213218
does not need to have `Rest Access` permissions. Also these requests
214-
bypass CSRF checks.
219+
bypass CSRF checks except for the Origin header check which is always
220+
run for preflight requests.
221+
222+
You can permit only allowed ORIGINS by setting ``allowed_api_origins``
223+
in ``config.ini`` to the list of origins permitted to access your
224+
api. By default only your tracker's origin is allowed. If a preflight
225+
request fails, the api request will be stopped by the browser.
215226

216-
The headers:
227+
The following CORS preflight headers are usually added automatically by
228+
the browser and must all be present:
217229

218230
* `Access-Control-Request-Headers`
219231
* `Access-Control-Request-Method`
220232
* `Origin`
221233

222-
must all be present. The 204 response will include:
234+
The 204 response will include the headers:
223235

224236
* `Access-Control-Allow-Origin`
225237
* `Access-Control-Allow-Headers`
@@ -238,10 +250,6 @@ cause a problem. If it is an issue, you can see
238250
`Creating Custom Rate Limits`_
239251
and craft a rate limiter that ignores anonymous OPTIONS requests.
240252

241-
Support for filtering by ORIGIN is not included. If you want to add
242-
this, see `Programming the REST API`_ on directions for overriding
243-
existing endpoints with a wrapper that can verify the ORIGIN.
244-
245253
Response Formats
246254
================
247255

roundup/cgi/client.py

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -581,7 +581,7 @@ def handle_xmlrpc(self):
581581
# Call csrf with xmlrpc checks enabled.
582582
# It will return True if everything is ok,
583583
# raises exception on check failure.
584-
csrf_ok = self.handle_csrf(xmlrpc=True)
584+
csrf_ok = self.handle_csrf(api=True)
585585
except (Unauthorised, UsageError) as msg:
586586
# report exception back to server
587587
exc_type, exc_value, exc_tb = sys.exc_info()
@@ -631,10 +631,10 @@ def handle_rest(self):
631631
self.check_anonymous_access()
632632

633633
try:
634-
# Call csrf with xmlrpc checks enabled.
634+
# Call csrf with api (xmlrpc, rest) checks enabled.
635635
# It will return True if everything is ok,
636636
# raises exception on check failure.
637-
csrf_ok = self.handle_csrf(xmlrpc=True)
637+
csrf_ok = self.handle_csrf(api=True)
638638
except (Unauthorised, UsageError) as msg:
639639
# report exception back to server
640640
exc_type, exc_value, exc_tb = sys.exc_info()
@@ -1207,8 +1207,28 @@ def check_anonymous_access(self):
12071207
raise Unauthorised(self._("Anonymous users are not "
12081208
"allowed to use the web interface"))
12091209

1210+
def is_origin_header_ok(self, api=False):
1211+
origin = self.env['HTTP_ORIGIN']
1212+
# note base https://host/... ends host with with a /,
1213+
# so add it to origin.
1214+
foundat = self.base.find(origin +'/')
1215+
if foundat == 0:
1216+
return True
1217+
1218+
if not api:
1219+
return False
12101220

1211-
def handle_csrf(self, xmlrpc=False):
1221+
allowed_origins = self.db.config['WEB_ALLOWED_API_ORIGINS']
1222+
# find a match for other possible origins
1223+
# Original spec says origin is case sensitive match.
1224+
# Living spec doesn't address Origin value's case or
1225+
# how to compare it. So implement case sensitive....
1226+
if allowed_origins[0] == '*' or origin in allowed_origins:
1227+
return True
1228+
1229+
return False
1230+
1231+
def handle_csrf(self, api=False):
12121232
'''Handle csrf token lookup and validate current user and session
12131233
12141234
This implements (or tries to implement) the
@@ -1332,9 +1352,8 @@ def handle_csrf(self, xmlrpc=False):
13321352
# self.base.find("") returns 0 for example not -1
13331353
enforce=config['WEB_CSRF_ENFORCE_HEADER_ORIGIN']
13341354
if 'HTTP_ORIGIN' in self.env and enforce != "no":
1335-
origin = self.env['HTTP_ORIGIN']
1336-
foundat = self.base.find(origin +'/')
1337-
if foundat != 0:
1355+
if not self.is_origin_header_ok(api=api):
1356+
origin = self.env['HTTP_ORIGIN']
13381357
if enforce in ('required', 'yes'):
13391358
logger.error(self._("csrf Origin header check failed for user%s. Value=%s"), current_user, origin)
13401359
raise Unauthorised(self._("Invalid Origin %s"%origin))
@@ -1384,13 +1403,13 @@ def handle_csrf(self, xmlrpc=False):
13841403
raise UsageError(self._("Unable to verify sufficient headers"))
13851404

13861405
enforce=config['WEB_CSRF_ENFORCE_HEADER_X-REQUESTED-WITH']
1387-
if xmlrpc:
1406+
if api:
13881407
if enforce in ['required', 'yes']:
13891408
# if we get here we have usually passed at least one
13901409
# header check. We check for presence of this custom
1391-
# header for xmlrpc calls only.
1410+
# header for xmlrpc/rest calls only.
13921411
# E.G. X-Requested-With: XMLHttpRequest
1393-
# Note we do not use CSRF nonces for xmlrpc requests.
1412+
# Note we do not use CSRF nonces for xmlrpc/rest requests.
13941413
#
13951414
# see: https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet#Protecting_REST_Services:_Use_of_Custom_Request_Headers
13961415
if 'HTTP_X_REQUESTED_WITH' not in self.env:
@@ -1405,7 +1424,7 @@ def handle_csrf(self, xmlrpc=False):
14051424
# our own.
14061425
otks.clean()
14071426

1408-
if xmlrpc:
1427+
if api:
14091428
# Save removal of expired keys from database.
14101429
otks.commit()
14111430
# Return from here since we have done housekeeping

roundup/configuration.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,38 @@ def get(self):
540540
_val = os.path.join(self.config["HOME"], _val)
541541
return _val
542542

543+
class SpaceSeparatedListOption(Option):
544+
545+
"""List of space seperated elements.
546+
"""
547+
548+
class_description = "A list of space separated elements."
549+
550+
def get(self):
551+
pathlist = []
552+
_val = Option.get(self)
553+
for elem in _val.split():
554+
pathlist.append(elem)
555+
if pathlist:
556+
return pathlist
557+
else:
558+
return None
559+
560+
class OriginHeadersListOption(Option):
561+
562+
"""List of space seperated origin header values.
563+
"""
564+
565+
class_description = "A list of space separated case sensitive origin headers 'scheme://host'."
566+
567+
568+
def set(self, _val):
569+
pathlist = self._value = []
570+
for elem in _val.split():
571+
pathlist.append(elem)
572+
if '*' in pathlist and len(pathlist) != 1:
573+
raise OptionValueError(self, _val,
574+
"If using '*' it must be the only element.")
543575

544576
class MultiFilePathOption(Option):
545577

@@ -1170,6 +1202,21 @@ def str2value(self, value):
11701202
log if the header is invalid or missing, but accept
11711203
the post.
11721204
Set this to 'no' to ignore the header and accept the post."""),
1205+
(OriginHeadersListOption, 'allowed_api_origins', "",
1206+
"""A comma separated list of additonal valid Origin header
1207+
values used when enforcing the header origin. They are used
1208+
only for the api URL's (/rest and /xmlrpc). They are not
1209+
used for the usual html URL's. These strings must match the
1210+
value of the Origin header exactly. So 'https://bar.edu' and
1211+
'https://Bar.edu' are two different Origin values. Note that
1212+
the origin value is scheme://host. There is no path
1213+
component. So 'https://bar.edu/' would never be valid.
1214+
1215+
You need to set these if you have a web application on a
1216+
different origin accessing your roundup instance.
1217+
1218+
(The origin from the tracker.web setting in config.ini is
1219+
always valid and does not need to be specified.)"""),
11731220
(CsrfSettingOption, 'csrf_enforce_header_x-forwarded-host', "yes",
11741221
"""Verify that the X-Forwarded-Host http header matches
11751222
the host part of the tracker.web setting in config.ini.

test/test_cgi.py

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -955,7 +955,7 @@ def testCsrfProtection(self):
955955
# need to set SENDMAILDEBUG to prevent
956956
# downstream issue when email is sent on successful
957957
# issue creation. Also delete the file afterwards
958-
# just tomake sure that someother test looking for
958+
# just to make sure that some other test looking for
959959
# SENDMAILDEBUG won't trip over ours.
960960
if 'SENDMAILDEBUG' not in os.environ:
961961
os.environ['SENDMAILDEBUG'] = 'mail-test1.log'
@@ -1157,6 +1157,18 @@ def hasPermission(s, p, classname=None, d=None, e=None, **kw):
11571157
del(out[0])
11581158

11591159
del(cl.env['HTTP_REFERER'])
1160+
1161+
# test by setting allowed api origins to *
1162+
# this should not redirect as it is not an API call.
1163+
cl.db.config.WEB_ALLOWED_API_ORIGINS = " * "
1164+
cl.env['HTTP_ORIGIN'] = 'https://baz.edu'
1165+
cl.inner_main()
1166+
match_at=out[0].find('Invalid Origin https://baz.edu')
1167+
print("result of subtest invalid origin:", out[0])
1168+
self.assertEqual(match_at, 36)
1169+
del(cl.env['HTTP_ORIGIN'])
1170+
cl.db.config.WEB_ALLOWED_API_ORIGINS = ""
1171+
del(out[0])
11601172

11611173
# clean up from email log
11621174
if os.path.exists(SENDMAILDEBUG):
@@ -1239,6 +1251,106 @@ def wh(s):
12391251
self.assertEqual(response,expected)
12401252
del(out[0])
12411253

1254+
1255+
# rest has no form content
1256+
cl.db.config.WEB_ALLOWED_API_ORIGINS = "https://bar.edu http://bar.edu"
1257+
form = cgi.FieldStorage()
1258+
form.list = [
1259+
cgi.MiniFieldStorage('title', 'A new issue'),
1260+
cgi.MiniFieldStorage('status', '1'),
1261+
cgi.MiniFieldStorage('@pretty', 'false'),
1262+
cgi.MiniFieldStorage('@apiver', '1'),
1263+
]
1264+
cl = client.Client(self.instance, None,
1265+
{'REQUEST_METHOD':'POST',
1266+
'PATH_INFO':'rest/data/issue',
1267+
'CONTENT_TYPE': 'application/x-www-form-urlencoded',
1268+
'HTTP_ORIGIN': 'https://bar.edu',
1269+
'HTTP_X_REQUESTED_WITH': 'rest',
1270+
'HTTP_AUTHORIZATION': 'Basic YWRtaW46YWRtaW4=',
1271+
'HTTP_REFERER': 'http://whoami.com/path/',
1272+
'HTTP_ACCEPT': "application/json;version=1"
1273+
}, form)
1274+
cl.db = self.db
1275+
cl.base = 'http://whoami.com/path/'
1276+
cl._socket_op = lambda *x : True
1277+
cl._error_message = []
1278+
cl.request = MockNull()
1279+
h = { 'content-type': 'application/json',
1280+
'accept': 'application/json' }
1281+
cl.request.headers = MockNull(**h)
1282+
1283+
cl.write = wh # capture output
1284+
1285+
# Should return explanation because content type is text/plain
1286+
# and not text/xml
1287+
cl.handle_rest()
1288+
answer='{"data": {"link": "http://tracker.example/cgi-bin/roundup.cgi/bugs/rest/data/issue/2", "id": "2"}}\n'
1289+
# check length to see if pretty is turned off.
1290+
self.assertEqual(len(out[0]), 99)
1291+
1292+
# compare as dicts not strings due to different key ordering
1293+
# between python versions.
1294+
response=json.loads(b2s(out[0]))
1295+
expected=json.loads(answer)
1296+
self.assertEqual(response,expected)
1297+
del(out[0])
1298+
1299+
#####
1300+
cl = client.Client(self.instance, None,
1301+
{'REQUEST_METHOD':'POST',
1302+
'PATH_INFO':'rest/data/issue',
1303+
'CONTENT_TYPE': 'application/x-www-form-urlencoded',
1304+
'HTTP_ORIGIN': 'httxs://bar.edu',
1305+
'HTTP_AUTHORIZATION': 'Basic YWRtaW46YWRtaW4=',
1306+
'HTTP_REFERER': 'http://whoami.com/path/',
1307+
'HTTP_ACCEPT': "application/json;version=1"
1308+
}, form)
1309+
cl.db = self.db
1310+
cl.base = 'http://whoami.com/path/'
1311+
cl._socket_op = lambda *x : True
1312+
cl._error_message = []
1313+
cl.request = MockNull()
1314+
h = { 'content-type': 'application/json',
1315+
'accept': 'application/json' }
1316+
cl.request.headers = MockNull(**h)
1317+
1318+
cl.write = wh # capture output
1319+
1320+
# Should return explanation because content type is text/plain
1321+
# and not text/xml
1322+
cl.handle_rest()
1323+
self.assertEqual(b2s(out[0]), "<class 'roundup.exceptions.Unauthorised'>: Invalid Origin httxs://bar.edu\n")
1324+
del(out[0])
1325+
1326+
1327+
cl.db.config.WEB_ALLOWED_API_ORIGINS = " * "
1328+
cl = client.Client(self.instance, None,
1329+
{'REQUEST_METHOD':'POST',
1330+
'PATH_INFO':'rest/data/issue',
1331+
'CONTENT_TYPE': 'application/x-www-form-urlencoded',
1332+
'HTTP_ORIGIN': 'httxs://bar.edu',
1333+
'HTTP_X_REQUESTED_WITH': 'rest',
1334+
'HTTP_AUTHORIZATION': 'Basic YWRtaW46YWRtaW4=',
1335+
'HTTP_REFERER': 'http://whoami.com/path/',
1336+
'HTTP_ACCEPT': "application/json;version=1"
1337+
}, form)
1338+
cl.db = self.db
1339+
cl.base = 'http://whoami.com/path/'
1340+
cl._socket_op = lambda *x : True
1341+
cl._error_message = []
1342+
cl.request = MockNull()
1343+
h = { 'content-type': 'application/json',
1344+
'accept': 'application/json' }
1345+
cl.request.headers = MockNull(**h)
1346+
1347+
cl.write = wh # capture output
1348+
1349+
# create third issue
1350+
cl.handle_rest()
1351+
self.assertIn('"id": "3"', b2s(out[0]))
1352+
del(out[0])
1353+
12421354
def testXmlrpcCsrfProtection(self):
12431355
# set the password for admin so we can log in.
12441356
passwd=password.Password('admin')
@@ -1663,12 +1775,11 @@ def testRegisterActionDelay(self):
16631775
# need to set SENDMAILDEBUG to prevent
16641776
# downstream issue when email is sent on successful
16651777
# issue creation. Also delete the file afterwards
1666-
# just tomake sure that someother test looking for
1778+
# just to make sure that some other test looking for
16671779
# SENDMAILDEBUG won't trip over ours.
16681780
if 'SENDMAILDEBUG' not in os.environ:
16691781
os.environ['SENDMAILDEBUG'] = 'mail-test1.log'
16701782
SENDMAILDEBUG = os.environ['SENDMAILDEBUG']
1671-
16721783

16731784
# missing opaqueregister
16741785
cl = self._make_client({'username':'new_user1', 'password':'secret',
@@ -1727,7 +1838,7 @@ def testRegisterActionUnusedUserCheck(self):
17271838
# need to set SENDMAILDEBUG to prevent
17281839
# downstream issue when email is sent on successful
17291840
# issue creation. Also delete the file afterwards
1730-
# just tomake sure that someother test looking for
1841+
# just to make sure that some other test looking for
17311842
# SENDMAILDEBUG won't trip over ours.
17321843
if 'SENDMAILDEBUG' not in os.environ:
17331844
os.environ['SENDMAILDEBUG'] = 'mail-test1.log'

test/test_config.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,18 @@ def testFloatAndInt_with_update_option(self):
279279
self.assertEqual("3",
280280
config._get_option('WEB_LOGIN_ATTEMPTS_MIN')._value2str(3.00))
281281

282+
def testOriginHeader(self):
283+
config = configuration.CoreConfig()
284+
285+
with self.assertRaises(configuration.OptionValueError) as cm:
286+
config._get_option('WEB_ALLOWED_API_ORIGINS').set("* https://foo.edu")
287+
288+
config._get_option('WEB_ALLOWED_API_ORIGINS').set("https://foo.edu HTTP://baR.edu")
289+
290+
self.assertEqual(config['WEB_ALLOWED_API_ORIGINS'][0], 'https://foo.edu')
291+
self.assertEqual(config['WEB_ALLOWED_API_ORIGINS'][1], 'HTTP://baR.edu')
292+
293+
282294

283295
def testOptionAsString(self):
284296

0 commit comments

Comments
 (0)