comparison roundup/rest.py @ 8177:2967f37e73e4

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.
author John Rouillard <rouilj@ieee.org>
date Sun, 08 Dec 2024 01:09:34 -0500
parents f7bd22bdef9d
children d02ce1d14acd
comparison
equal deleted inserted replaced
8176:736f769b48c8 8177:2967f37e73e4
265 It will actually convert the vendor and version into parameters and 265 It will actually convert the vendor and version into parameters and
266 convert the content type into `application/json` so appropriate content 266 convert the content type into `application/json` so appropriate content
267 negotiation decisions can be made. 267 negotiation decisions can be made.
268 268
269 Default `q` for values that are not specified is 1.0 269 Default `q` for values that are not specified is 1.0
270
271 If q value > 1.0, it is parsed as a very small value.
270 272
271 # Based on https://gist.github.com/samuraisam/2714195 273 # Based on https://gist.github.com/samuraisam/2714195
272 # Also, based on a snipped found in this project: 274 # Also, based on a snipped found in this project:
273 # https://github.com/martinblech/mimerender 275 # https://github.com/martinblech/mimerender
274 """ 276 """
2204 output, 2206 output,
2205 True # pretty print for this error case as a 2207 True # pretty print for this error case as a
2206 # human may read it 2208 # human may read it
2207 ), None) 2209 ), None)
2208 2210
2209 def dispatch(self, method, uri, input): 2211 def determine_output_format(self, uri):
2210 """format and process the request""" 2212 """Returns tuple of:
2211 output = None 2213
2212 2214 (format for returned output,
2213 # Before we do anything has the user hit the rate limit. 2215 possibly modified uri,
2214 2216 output error object (if error else None))
2215 # Get the limit here and not in the init() routine to allow 2217
2216 # for a different rate limit per user. 2218 Verify that client is requesting an output we can produce
2217 apiRateLimit = self.getRateLimit() 2219 with a version we support.
2218 2220
2219 if apiRateLimit: # if None, disable rate limiting 2221 Only application/json and application/xml (optional)
2220 LimitExceeded, limitStatus = self.handle_apiRateLimitExceeded( 2222 are currently supported. .vcard might be useful
2221 apiRateLimit) 2223 in the future for user objects.
2222 if LimitExceeded: 2224
2223 return LimitExceeded # error message 2225 Find format for returned output:
2224 2226
2225 for header, value in limitStatus.items(): 2227 1) Get format from url extension (.json, .xml) and return
2226 # Retry-After will be 0 because 2228 if invalid return (None, uri, 406 error)
2227 # user still has quota available. 2229 if not found continue
2228 # Don't put out the header. 2230 2) Parse Accept header obeying q values
2229 if header in ('Retry-After',): 2231 if header unparsible return 400 error object.
2230 continue 2232 3) if empty or missing Accept header
2231 self.client.setHeader(header, value) 2233 return self.__default_accept_type
2232 2234 4) match and return best Accept header/version
2233 # if X-HTTP-Method-Override is set, follow the override method 2235 if version error found in matching type return 406 error
2234 headers = self.client.request.headers 2236 5) if no requested format is supported return 406
2235 # Never allow GET to be an unsafe operation (i.e. data changing). 2237 error
2236 # User must use POST to "tunnel" DELETE, PUT, OPTIONS etc. 2238
2237 override = headers.get('X-HTTP-Method-Override') 2239 """
2238 if override: 2240 # get the request format for response
2239 if method.upper() == 'POST': 2241 # priority : extension from uri (/rest/data/issue.json)
2240 logger.debug( 2242 # only json or xml valid at this time.
2241 'Method overridden from %s to %s', method, override) 2243 # header (Accept: application/json, application/xml)
2242 method = override 2244 # default (application/json)
2243 else: 2245 ext_type = os.path.splitext(urlparse(uri).path)[1][1:]
2244 output = self.error_obj(400, 2246
2245 "X-HTTP-Method-Override: %s must be used with " 2247 # Check to see if the extension matches a value in
2246 "POST method not %s." % (override, method.upper())) 2248 # self.__accepted_content_type. In the future other output
2247 logger.info( 2249 # such as .vcard for downloading user info can be
2248 'Ignoring X-HTTP-Method-Override using %s request on %s', 2250 # supported. This method also allow detection of mistyped
2249 method.upper(), uri) 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)
2250 2272
2251 # parse Accept header and get the content type 2273 # parse Accept header and get the content type
2252 # Acceptable types ordered with preferred one first 2274 # Acceptable types ordered with preferred one (by q value)
2253 # in list. 2275 # first in list.
2254 try: 2276 try:
2255 accept_header = parse_accept_header(headers.get('Accept')) 2277 accept_header = parse_accept_header(
2278 self.client.request.headers.get('Accept')
2279 )
2256 except UsageError as e: 2280 except UsageError as e:
2257 output = self.error_obj(406, _("Unable to parse Accept Header. %(error)s. " 2281 self.client.response_code = 406
2258 "Acceptable types: %(acceptable_types)s") % { 2282 return (None, uri, self.error_obj(
2259 'error': e.args[0], 2283 400, _("Unable to parse Accept Header. %(error)s. "
2260 'acceptable_types': " ".join(sorted(self.__accepted_content_type.keys()))}) 2284 "Acceptable types: %(acceptable_types)s") % {
2261 accept_header = [] 2285 'error': e.args[0],
2286 'acceptable_types': " ".join(sorted(
2287 self.__accepted_content_type.keys()))}))
2262 2288
2263 if not accept_header: 2289 if not accept_header:
2264 accept_type = self.__default_accept_type 2290 # we are using the default
2265 else: 2291 return (self.__default_accept_type, uri, None)
2266 accept_type = None 2292
2293 accept_type = ""
2267 for part in accept_header: 2294 for part in accept_header:
2268 if accept_type: 2295 if accept_type:
2269 # we accepted the best match, stop searching for 2296 # we accepted the best match, stop searching for
2270 # lower quality matches. 2297 # lower quality matches.
2271 break 2298 break
2285 try: 2312 try:
2286 self.api_version = int(part[1]['version']) 2313 self.api_version = int(part[1]['version'])
2287 except KeyError: 2314 except KeyError:
2288 self.api_version = None 2315 self.api_version = None
2289 except (ValueError, TypeError): 2316 except (ValueError, TypeError):
2317 self.client.response_code = 406
2290 # TypeError if int(None) 2318 # TypeError if int(None)
2291 msg = ("Unrecognized api version: %s. " 2319 msg = _("Unrecognized api version: %s. "
2292 "See /rest without specifying api version " 2320 "See /rest without specifying api version "
2293 "for supported versions." % ( 2321 "for supported versions.") % (
2294 part[1]['version'])) 2322 part[1]['version'])
2295 output = self.error_obj(400, msg) 2323 return (None, uri,
2296 2324 self.error_obj(406, msg))
2297 # get the request format for response 2325
2298 # priority : extension from uri (/rest/data/issue.json), 2326 # accept_type will be empty only if there is an Accept header
2299 # header (Accept: application/json, application/xml)
2300 # default (application/json)
2301 ext_type = os.path.splitext(urlparse(uri).path)[1][1:]
2302
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. 2327 # with invalid values.
2319 data_type = ext_type or accept_type or headers.get('Accept') or "invalid" 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
2341 def dispatch(self, method, uri, input):
2342 """format and process the request"""
2343 output = None
2344
2345 # Before we do anything has the user hit the rate limit.
2346
2347 # Get the limit here and not in the init() routine to allow
2348 # for a different rate limit per user.
2349 apiRateLimit = self.getRateLimit()
2350
2351 if apiRateLimit: # if None, disable rate limiting
2352 LimitExceeded, limitStatus = self.handle_apiRateLimitExceeded(
2353 apiRateLimit)
2354 if LimitExceeded:
2355 return LimitExceeded # error message
2356
2357 for header, value in limitStatus.items():
2358 # Retry-After will be 0 because
2359 # user still has quota available.
2360 # Don't put out the header.
2361 if header in ('Retry-After',):
2362 continue
2363 self.client.setHeader(header, value)
2364
2365 # if X-HTTP-Method-Override is set, follow the override method
2366 headers = self.client.request.headers
2367 # Never allow GET to be an unsafe operation (i.e. data changing).
2368 # User must use POST to "tunnel" DELETE, PUT, OPTIONS etc.
2369 override = headers.get('X-HTTP-Method-Override')
2370 if override:
2371 if method.upper() == 'POST':
2372 logger.debug(
2373 'Method overridden from %s to %s', method, override)
2374 method = override
2375 else:
2376 output = self.error_obj(400,
2377 "X-HTTP-Method-Override: %s must be used with "
2378 "POST method not %s." % (override, method.upper()))
2379 logger.info(
2380 'Ignoring X-HTTP-Method-Override using %s request on %s',
2381 method.upper(), uri)
2382
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
2391
2320 if method.upper() == 'OPTIONS': 2392 if method.upper() == 'OPTIONS':
2321 # add access-control-allow-* access-control-max-age to support 2393 # add access-control-allow-* access-control-max-age to support
2322 # CORS preflight 2394 # CORS preflight
2323 self.client.setHeader( 2395 self.client.setHeader(
2324 "Access-Control-Allow-Headers", 2396 "Access-Control-Allow-Headers",
2439 # check for @apiver in query string 2511 # check for @apiver in query string
2440 msg = _("Unrecognized api version: %s. " 2512 msg = _("Unrecognized api version: %s. "
2441 "See /rest without specifying api version " 2513 "See /rest without specifying api version "
2442 "for supported versions.") 2514 "for supported versions.")
2443 try: 2515 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.
2444 if not self.api_version: 2519 if not self.api_version:
2445 self.api_version = int(input['@apiver'].value) 2520 self.api_version = int(input['@apiver'].value)
2446 # Can also return a TypeError ("not indexable") 2521 # Can also return a TypeError ("not indexable")
2447 # In case the FieldStorage could not parse the result 2522 # In case the FieldStorage could not parse the result
2448 except (KeyError, TypeError): 2523 except (KeyError, TypeError):
2449 self.api_version = None 2524 self.api_version = None
2450 except ValueError: 2525 except ValueError:
2451 output = self.error_obj(400, msg % input['@apiver'].value) 2526 output = self.error_obj(406, msg % input['@apiver'].value)
2452 2527
2453 # by this time the API version is set. Error if we don't 2528 # by this time the API version is set. Error if we don't
2454 # support it? 2529 # support it?
2455 if self.api_version is None: 2530 if self.api_version is None:
2456 # FIXME: do we need to raise an error if client did not specify 2531 # FIXME: do we need to raise an error if client did not specify
2457 # version? This may be a good thing to require. Note that: 2532 # version? This may be a good thing to require. Note that:
2458 # Accept: application/json; version=1 may not be legal but.... 2533 # Accept: application/json; version=1 may not be legal but....
2459 # Use default if not specified for now. 2534 # Use default if not specified for now.
2460 self.api_version = self.__default_api_version 2535 self.api_version = self.__default_api_version
2461 elif self.api_version not in self.__supported_api_versions: 2536 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)
2463 2538
2464 # sadly del doesn't work on FieldStorage which can be the type of 2539 # sadly del doesn't work on FieldStorage which can be the type of
2465 # input. So we have to ignore keys starting with @ at other 2540 # input. So we have to ignore keys starting with @ at other
2466 # places in the code. 2541 # places in the code.
2467 # else: 2542 # else:
2477 output = self.error_obj(404, msg) 2552 output = self.error_obj(404, msg)
2478 except Reject as msg: 2553 except Reject as msg:
2479 output = self.error_obj(405, msg.args[0]) 2554 output = self.error_obj(405, msg.args[0])
2480 self.client.setHeader("Allow", msg.args[1]) 2555 self.client.setHeader("Allow", msg.args[1])
2481 2556
2482 return self.format_dispatch_output(data_type, output, pretty_output) 2557 return self.format_dispatch_output(output_format,
2558 output,
2559 pretty_output)
2483 2560
2484 def format_dispatch_output(self, accept_mime_type, output, 2561 def format_dispatch_output(self, accept_mime_type, output,
2485 pretty_print=True): 2562 pretty_print=True):
2486 # Format the content type 2563 # 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:
2488 self.client.setHeader("Content-Type", "application/json") 2567 self.client.setHeader("Content-Type", "application/json")
2489 if pretty_print: 2568 if pretty_print:
2490 indent = 4 2569 indent = 4
2491 else: 2570 else:
2492 indent = None 2571 indent = None
2493 output = RoundupJSONEncoder(indent=indent).encode(output) 2572 output = RoundupJSONEncoder(indent=indent).encode(output)
2494 elif accept_mime_type.lower() == "xml" and dicttoxml: 2573 elif accept_mime_type == "xml" and dicttoxml:
2495 self.client.setHeader("Content-Type", "application/xml") 2574 self.client.setHeader("Content-Type", "application/xml")
2496 if 'error' in output: 2575 if 'error' in output:
2497 # capture values in error with types unsupported 2576 # capture values in error with types unsupported
2498 # by dicttoxml e.g. an exception, into something it 2577 # by dicttoxml e.g. an exception, into something it
2499 # can handle 2578 # can handle

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