Skip to content

Commit 4dd59a3

Browse files
committed
refactor: issue2551289. invalid REST Accept header stops request
Sending a POST, PUT (maybe PATCH) with an accept header that is not application/json or xml (if enabled) used to complete the request before throwing a 406 error. This was wrong. Now it reports an error without dispatching/processing the requested transaction. This is the first of a series of refactors of the dispatch method to make it faster and more readable by using return early pattern and extracting methods from the code. changes: The following now return 406 errors not 400 errors invalid version specified with @Apiver in URL. invalid version specified with @Apiver in payload body invalid version specified in accept headers as application/vnd.roundup.test-vz+json or version property Parsing the accept header returns a 400 when presented with a parameter without an = sign or other parse error. They used to return a 406 which is wrong since the header is malformed rather than having a value I can't respond to. Some error messages were made clearer. Results in the case of an error are proper json error object rather than text/plain strings. New test added for testdetermine_output_formatBadAccept that test the new method using the same test cases as for testDispatchBadAccept. I intend to extend the test coverage for determine_output_format to cover more cases. This should be a faster unit test than for dispatch. Removed .lower() calls for accept_mime_type as the input values are taken from the values in the __accepted_content_type dict which only has lower case values.
1 parent 9f57c7b commit 4dd59a3

File tree

4 files changed

+324
-99
lines changed

4 files changed

+324
-99
lines changed

CHANGES.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ Fixed:
3333
- issue2551372 - Better document necessary headers for REST and fix
3434
logging to log missing Origin header (Ralf Schlatterbeck with
3535
suggestions on documentation by John Rouillard)
36+
- issue2551289 - Invalid REST Accept header with post/put performs
37+
change before returning 406. Error before making any changes to the
38+
db if we can't respond with requested format. (John Rouillard)
3639

3740
Features:
3841

doc/upgrading.txt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,18 @@ See
149149
https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#cookie_prefixes
150150
for details on this security measure.
151151

152+
Invalid accept header now prevents operation (info)
153+
---------------------------------------------------
154+
155+
In earlier versions, the rest interface checked for an incorrect
156+
"Accept" header, "@apiver", or the ".json" mime type only after
157+
processing the request. This would lead to a 406 error, but the
158+
requested change would still be completed.
159+
160+
In this release, the validation of the output format and version
161+
occurs before any database changes are made. Now, all errors related
162+
to the data format (mime type, API version) will return 406 errors,
163+
where some previously resulted in 400 errors.
152164

153165
.. index:: Upgrading; 2.3.0 to 2.4.0
154166

roundup/rest.py

Lines changed: 152 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,8 @@ def parse_accept_header(accept):
268268
269269
Default `q` for values that are not specified is 1.0
270270
271+
If q value > 1.0, it is parsed as a very small value.
272+
271273
# Based on https://gist.github.com/samuraisam/2714195
272274
# Also, based on a snipped found in this project:
273275
# https://github.com/martinblech/mimerender
@@ -2206,6 +2208,136 @@ def handle_apiRateLimitExceeded(self, apiRateLimit):
22062208
# human may read it
22072209
), None)
22082210

2211+
def determine_output_format(self, uri):
2212+
"""Returns tuple of:
2213+
2214+
(format for returned output,
2215+
possibly modified uri,
2216+
output error object (if error else None))
2217+
2218+
Verify that client is requesting an output we can produce
2219+
with a version we support.
2220+
2221+
Only application/json and application/xml (optional)
2222+
are currently supported. .vcard might be useful
2223+
in the future for user objects.
2224+
2225+
Find format for returned output:
2226+
2227+
1) Get format from url extension (.json, .xml) and return
2228+
if invalid return (None, uri, 406 error)
2229+
if not found continue
2230+
2) Parse Accept header obeying q values
2231+
if header unparsible return 400 error object.
2232+
3) if empty or missing Accept header
2233+
return self.__default_accept_type
2234+
4) match and return best Accept header/version
2235+
if version error found in matching type return 406 error
2236+
5) if no requested format is supported return 406
2237+
error
2238+
2239+
"""
2240+
# get the request format for response
2241+
# priority : extension from uri (/rest/data/issue.json)
2242+
# only json or xml valid at this time.
2243+
# header (Accept: application/json, application/xml)
2244+
# default (application/json)
2245+
ext_type = os.path.splitext(urlparse(uri).path)[1][1:]
2246+
2247+
# Check to see if the extension matches a value in
2248+
# self.__accepted_content_type. In the future other output
2249+
# such as .vcard for downloading user info can be
2250+
# supported. This method also allow detection of mistyped
2251+
# types like jon for json. Limit extension length to less than
2252+
# 10 characters to allow passing JWT via URL path. Could be
2253+
# useful for magic link login method or account recovery workflow,
2254+
# using a JWT with a short expiration time and limited rights
2255+
# (e.g. only password change permission))
2256+
if ext_type and (len(ext_type) < 10):
2257+
if ext_type not in list(self.__accepted_content_type.values()):
2258+
self.client.response_code = 406
2259+
return (None, uri,
2260+
self.error_obj(
2261+
406,
2262+
_("Content type '%s' requested in URL is "
2263+
"not available.\nAcceptable types: %s\n") %
2264+
(ext_type,
2265+
", ".join(sorted(
2266+
set(self.__accepted_content_type.values()))))))
2267+
2268+
# strip extension so uri makes sense.
2269+
# E.G. .../issue.json -> .../issue
2270+
uri = uri[:-(len(ext_type) + 1)]
2271+
return (ext_type, uri, None)
2272+
2273+
# parse Accept header and get the content type
2274+
# Acceptable types ordered with preferred one (by q value)
2275+
# first in list.
2276+
try:
2277+
accept_header = parse_accept_header(
2278+
self.client.request.headers.get('Accept')
2279+
)
2280+
except UsageError as e:
2281+
self.client.response_code = 406
2282+
return (None, uri, self.error_obj(
2283+
400, _("Unable to parse Accept Header. %(error)s. "
2284+
"Acceptable types: %(acceptable_types)s") % {
2285+
'error': e.args[0],
2286+
'acceptable_types': " ".join(sorted(
2287+
self.__accepted_content_type.keys()))}))
2288+
2289+
if not accept_header:
2290+
# we are using the default
2291+
return (self.__default_accept_type, uri, None)
2292+
2293+
accept_type = ""
2294+
for part in accept_header:
2295+
if accept_type:
2296+
# we accepted the best match, stop searching for
2297+
# lower quality matches.
2298+
break
2299+
if part[0] in self.__accepted_content_type:
2300+
accept_type = self.__accepted_content_type[part[0]]
2301+
# Version order:
2302+
# 1) accept header version=X specifier
2303+
# application/vnd.x.y; version=1
2304+
# 2) from type in accept-header type/subtype-vX
2305+
# application/vnd.x.y-v1
2306+
# 3) from @apiver in query string to make browser
2307+
# use easy
2308+
# This code handles 1 and 2. Set api_version to none
2309+
# to trigger @apiver parsing below
2310+
# Places that need the api_version info should
2311+
# use default if version = None
2312+
try:
2313+
self.api_version = int(part[1]['version'])
2314+
except KeyError:
2315+
self.api_version = None
2316+
except (ValueError, TypeError):
2317+
self.client.response_code = 406
2318+
# TypeError if int(None)
2319+
msg = _("Unrecognized api version: %s. "
2320+
"See /rest without specifying api version "
2321+
"for supported versions.") % (
2322+
part[1]['version'])
2323+
return (None, uri,
2324+
self.error_obj(406, msg))
2325+
2326+
# accept_type will be empty only if there is an Accept header
2327+
# with invalid values.
2328+
if accept_type:
2329+
return (accept_type, uri, None)
2330+
2331+
self.client.response_code = 400
2332+
return (None, uri,
2333+
self.error_obj(
2334+
406,
2335+
_("Requested content type(s) '%s' not available.\n"
2336+
"Acceptable mime types are: %s") %
2337+
(self.client.request.headers.get('Accept'),
2338+
", ".join(sorted(
2339+
self.__accepted_content_type.keys())))))
2340+
22092341
def dispatch(self, method, uri, input):
22102342
"""format and process the request"""
22112343
output = None
@@ -2248,75 +2380,15 @@ def dispatch(self, method, uri, input):
22482380
'Ignoring X-HTTP-Method-Override using %s request on %s',
22492381
method.upper(), uri)
22502382

2251-
# parse Accept header and get the content type
2252-
# Acceptable types ordered with preferred one first
2253-
# in list.
2254-
try:
2255-
accept_header = parse_accept_header(headers.get('Accept'))
2256-
except UsageError as e:
2257-
output = self.error_obj(406, _("Unable to parse Accept Header. %(error)s. "
2258-
"Acceptable types: %(acceptable_types)s") % {
2259-
'error': e.args[0],
2260-
'acceptable_types': " ".join(sorted(self.__accepted_content_type.keys()))})
2261-
accept_header = []
2262-
2263-
if not accept_header:
2264-
accept_type = self.__default_accept_type
2265-
else:
2266-
accept_type = None
2267-
for part in accept_header:
2268-
if accept_type:
2269-
# we accepted the best match, stop searching for
2270-
# lower quality matches.
2271-
break
2272-
if part[0] in self.__accepted_content_type:
2273-
accept_type = self.__accepted_content_type[part[0]]
2274-
# Version order:
2275-
# 1) accept header version=X specifier
2276-
# application/vnd.x.y; version=1
2277-
# 2) from type in accept-header type/subtype-vX
2278-
# application/vnd.x.y-v1
2279-
# 3) from @apiver in query string to make browser
2280-
# use easy
2281-
# This code handles 1 and 2. Set api_version to none
2282-
# to trigger @apiver parsing below
2283-
# Places that need the api_version info should
2284-
# use default if version = None
2285-
try:
2286-
self.api_version = int(part[1]['version'])
2287-
except KeyError:
2288-
self.api_version = None
2289-
except (ValueError, TypeError):
2290-
# TypeError if int(None)
2291-
msg = ("Unrecognized api version: %s. "
2292-
"See /rest without specifying api version "
2293-
"for supported versions." % (
2294-
part[1]['version']))
2295-
output = self.error_obj(400, msg)
2296-
2297-
# get the request format for response
2298-
# priority : extension from uri (/rest/data/issue.json),
2299-
# header (Accept: application/json, application/xml)
2300-
# default (application/json)
2301-
ext_type = os.path.splitext(urlparse(uri).path)[1][1:]
2383+
# FIXME: when this method is refactored, change
2384+
# determine_output_format to raise an exception. Catch it here
2385+
# and return early. Also set self.client.response_code from
2386+
# error object['error']['status'] and remove from
2387+
# determine_output_format.
2388+
(output_format, uri, error) = self.determine_output_format(uri)
2389+
if error:
2390+
output = error
23022391

2303-
# Check to see if the length of the extension is less than 6.
2304-
# this allows use of .vcard for a future use in downloading
2305-
# user info. It also allows passing through larger items like
2306-
# JWT that has a final component > 6 items. This method also
2307-
# allow detection of mistyped types like jon for json.
2308-
if ext_type and (len(ext_type) < 6):
2309-
# strip extension so uri make sense
2310-
# .../issue.json -> .../issue
2311-
uri = uri[:-(len(ext_type) + 1)]
2312-
else:
2313-
ext_type = None
2314-
2315-
# headers.get('Accept') is never empty if called here.
2316-
# accept_type will be set to json if there is no Accept header
2317-
# accept_type wil be empty only if there is an Accept header
2318-
# with invalid values.
2319-
data_type = ext_type or accept_type or headers.get('Accept') or "invalid"
23202392
if method.upper() == 'OPTIONS':
23212393
# add access-control-allow-* access-control-max-age to support
23222394
# CORS preflight
@@ -2441,14 +2513,17 @@ def dispatch(self, method, uri, input):
24412513
"See /rest without specifying api version "
24422514
"for supported versions.")
24432515
try:
2516+
# FIXME: the version priority here is different
2517+
# from accept header. accept mime type in url
2518+
# takes priority over Accept header. Opposite here.
24442519
if not self.api_version:
24452520
self.api_version = int(input['@apiver'].value)
24462521
# Can also return a TypeError ("not indexable")
24472522
# In case the FieldStorage could not parse the result
24482523
except (KeyError, TypeError):
24492524
self.api_version = None
24502525
except ValueError:
2451-
output = self.error_obj(400, msg % input['@apiver'].value)
2526+
output = self.error_obj(406, msg % input['@apiver'].value)
24522527

24532528
# by this time the API version is set. Error if we don't
24542529
# support it?
@@ -2459,7 +2534,7 @@ def dispatch(self, method, uri, input):
24592534
# Use default if not specified for now.
24602535
self.api_version = self.__default_api_version
24612536
elif self.api_version not in self.__supported_api_versions:
2462-
output = self.error_obj(400, msg % self.api_version)
2537+
output = self.error_obj(406, msg % self.api_version)
24632538

24642539
# sadly del doesn't work on FieldStorage which can be the type of
24652540
# input. So we have to ignore keys starting with @ at other
@@ -2479,19 +2554,23 @@ def dispatch(self, method, uri, input):
24792554
output = self.error_obj(405, msg.args[0])
24802555
self.client.setHeader("Allow", msg.args[1])
24812556

2482-
return self.format_dispatch_output(data_type, output, pretty_output)
2557+
return self.format_dispatch_output(output_format,
2558+
output,
2559+
pretty_output)
24832560

24842561
def format_dispatch_output(self, accept_mime_type, output,
24852562
pretty_print=True):
24862563
# Format the content type
2487-
if accept_mime_type.lower() == "json":
2564+
# if accept_mime_type is None, the client specified invalid
2565+
# mime types so we default to json output.
2566+
if accept_mime_type == "json" or accept_mime_type is None:
24882567
self.client.setHeader("Content-Type", "application/json")
24892568
if pretty_print:
24902569
indent = 4
24912570
else:
24922571
indent = None
24932572
output = RoundupJSONEncoder(indent=indent).encode(output)
2494-
elif accept_mime_type.lower() == "xml" and dicttoxml:
2573+
elif accept_mime_type == "xml" and dicttoxml:
24952574
self.client.setHeader("Content-Type", "application/xml")
24962575
if 'error' in output:
24972576
# capture values in error with types unsupported

0 commit comments

Comments
 (0)