Skip to content

Commit ce3441d

Browse files
committed
better rest Origin check; refactor CORS preflight code.
A previous version allowed requests without an origin that should require it (e.g. an OPTIONS or PATCH request). Moved the origin checking logic into the main flow. It looks like this was limited to OPTIONS/PATCH requests as handle_csrf() (called later in the main flow) handles POST, PUT, DELETE verbs. Refactored CORS preflight request code into functions and call them from main flow. Also return immediately. Prior code processed the options request a second time due to falling through. Modified is_origin_header_ok to return True if origin was missing and it was a get request. Fixed tests that make OPTIONS requests to supply origin. Comment fixups.
1 parent 6651109 commit ce3441d

File tree

2 files changed

+77
-28
lines changed

2 files changed

+77
-28
lines changed

roundup/cgi/client.py

Lines changed: 59 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,25 @@ def handle_xmlrpc(self):
621621
self.setHeader("Content-Length", str(len(output)))
622622
self.write(output)
623623

624+
def is_cors_preflight(self):
625+
return (
626+
self.env['REQUEST_METHOD'] == "OPTIONS"
627+
and self.request.headers.get("Access-Control-Request-Headers")
628+
and self.request.headers.get("Access-Control-Request-Method")
629+
and self.request.headers.get("Origin"))
630+
631+
def handle_preflight(self):
632+
# Call rest library to handle the pre-flight request
633+
handler = rest.RestfulInstance(self, self.db)
634+
output = handler.dispatch(self.env['REQUEST_METHOD'],
635+
self.path, self.form)
636+
637+
if self.response_code == 204:
638+
self.write("")
639+
else:
640+
self.setHeader("Content-Length", str(len(output)))
641+
self.write(output)
642+
624643
def handle_rest(self):
625644
# Set the charset and language
626645
self.determine_charset()
@@ -640,32 +659,32 @@ def handle_rest(self):
640659
self.write(output)
641660
return
642661

643-
# allow preflight request even if unauthenticated
644-
if (
645-
self.env['REQUEST_METHOD'] == "OPTIONS"
646-
and self.request.headers.get("Access-Control-Request-Headers")
647-
and self.request.headers.get("Access-Control-Request-Method")
648-
and self.request.headers.get("Origin")
649-
):
650-
# verify Origin is allowed
651-
if not self.is_origin_header_ok(api=True):
652-
# Use code 400 as 401 and 403 imply that authentication
653-
# is needed or authenticates person is not authorized.
654-
# Preflight doesn't do authentication.
655-
self.response_code = 400
662+
# verify Origin is allowed on all requests including GET.
663+
# If a GET, missing origin is allowed (i.e. same site GET request)
664+
if not self.is_origin_header_ok(api=True):
665+
# Use code 400. Codes 401 and 403 imply that authentication
666+
# is needed or authenticated person is not authorized.
667+
# Preflight doesn't do authentication.
668+
self.response_code = 400
669+
670+
if 'HTTP_ORIGIN' not in self.env:
671+
msg = self._("Required Header Missing")
672+
else:
656673
msg = self._("Client is not allowed to use Rest Interface.")
657-
output = s2b(
658-
'{ "error": { "status": 400, "msg": "%s" } }' % msg)
659-
self.setHeader("Content-Length", str(len(output)))
660-
self.setHeader("Content-Type", "application/json")
661-
self.write(output)
662-
return
663674

664-
# Call rest library to handle the pre-flight request
665-
handler = rest.RestfulInstance(self, self.db)
666-
output = handler.dispatch(self.env['REQUEST_METHOD'],
667-
self.path, self.form)
675+
output = s2b(
676+
'{ "error": { "status": 400, "msg": "%s" } }' % msg)
677+
self.setHeader("Content-Length", str(len(output)))
678+
self.setHeader("Content-Type", "application/json")
679+
self.write(output)
680+
return
668681

682+
# Handle CORS preflight request. We know rest is enabled
683+
# because handle_rest is called. Preflight requests
684+
# are unauthenticated, so no need to check permissions.
685+
if ( self.is_cors_preflight() ):
686+
self.handle_preflight()
687+
return
669688
elif not self.db.security.hasPermission('Rest Access', self.userid):
670689
self.response_code = 403
671690
output = s2b('{ "error": { "status": 403, "msg": "Forbidden." } }')
@@ -680,6 +699,8 @@ def handle_rest(self):
680699
# Call csrf with api (xmlrpc, rest) checks enabled.
681700
# It will return True if everything is ok,
682701
# raises exception on check failure.
702+
# Note this returns true for a GET request.
703+
# Must check supplied Origin header for bad value first.
683704
csrf_ok = self.handle_csrf(api=True)
684705
except (Unauthorised, UsageError) as msg:
685706
# FIXME should return what the client requests
@@ -695,7 +716,7 @@ def handle_rest(self):
695716

696717
# With the return above the if will never be false,
697718
# Keeping the if so we can remove return to pass
698-
# output though and format output according to accept
719+
# output though and format output according to accept
699720
# header.
700721
if csrf_ok is True:
701722
# Call rest library to handle the request
@@ -1259,10 +1280,23 @@ def check_anonymous_access(self):
12591280
"allowed to use the web interface"))
12601281

12611282
def is_origin_header_ok(self, api=False):
1283+
"""Determine if origin is valid for the context
1284+
1285+
Allow (return True) if ORIGIN is missing and it is a GET.
1286+
Allow if ORIGIN matches the base url.
1287+
If this is a API call:
1288+
Allow if ORIGIN matches an element of allowed_api_origins.
1289+
Allow if allowed_api_origins includes '*' as first element..
1290+
Otherwise disallow.
1291+
"""
1292+
12621293
try:
12631294
origin = self.env['HTTP_ORIGIN']
12641295
except KeyError:
1265-
return False
1296+
if self.env['REQUEST_METHOD'] == 'GET':
1297+
return True
1298+
else:
1299+
return False
12661300

12671301
# note base https://host/... ends host with with a /,
12681302
# so add it to origin.

test/test_liveserver.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,20 @@ def test_rest_preflight_collection(self):
424424
'allowed to use Rest Interface." } }'
425425
self.assertEqual(b2s(f.content), expected)
426426

427+
# Test when Origin is not sent.
428+
f = requests.options(self.url_base() + '/rest/data/user',
429+
headers = {'content-type': "application/json",
430+
'x-requested-with': "rest",
431+
'Access-Control-Request-Headers':
432+
"x-requested-with",
433+
'Access-Control-Request-Method': "PUT",})
434+
435+
self.assertEqual(f.status_code, 400)
436+
437+
expected = ('{ "error": { "status": 400, "msg": "Required'
438+
' Header Missing" } }')
439+
self.assertEqual(b2s(f.content), expected)
440+
427441

428442
def test_rest_invalid_method_collection(self):
429443
# use basic auth for rest endpoint
@@ -595,7 +609,8 @@ def test_rest_endpoint_attribute_options(self):
595609
## test a property that doesn't exist
596610
f = requests.options(self.url_base() + '/rest/data/user/1/zot',
597611
auth=('admin', 'sekrit'),
598-
headers = {'content-type': ""})
612+
headers = {'content-type': "",
613+
'Origin': "http://localhost:9001",})
599614
print(f.status_code)
600615
print(f.headers)
601616

@@ -936,15 +951,15 @@ def test_compression_gzip(self, method='gzip'):
936951
headers = {'content-type': "",
937952
'Accept-Encoding': '%s, foo'%method,
938953
'Accept': '*/*',
939-
'Origin': 'ZZZZ'})
954+
'Origin': 'https://client.com'})
940955
print(f.status_code)
941956
print(f.headers)
942957

943958
# NOTE: not compressed payload too small
944959
self.assertEqual(f.status_code, 400)
945960
expected = { 'Content-Type': 'application/json',
946961
'Access-Control-Allow-Credentials': 'true',
947-
'Access-Control-Allow-Origin': 'ZZZZ',
962+
'Access-Control-Allow-Origin': 'https://client.com',
948963
'Allow': 'OPTIONS, GET, POST, PUT, DELETE, PATCH',
949964
'Vary': 'Origin'
950965
}

0 commit comments

Comments
 (0)