##// END OF EJS Templates
api: fixed potential crash when returning error response using JSON objects that fail to parse.
marcink -
r3316:899b726f default
parent child Browse files
Show More
@@ -1,542 +1,542 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import inspect
22 22 import itertools
23 23 import logging
24 24 import types
25 25 import fnmatch
26 26
27 27 import decorator
28 28 import venusian
29 29 from collections import OrderedDict
30 30
31 31 from pyramid.exceptions import ConfigurationError
32 32 from pyramid.renderers import render
33 33 from pyramid.response import Response
34 34 from pyramid.httpexceptions import HTTPNotFound
35 35
36 36 from rhodecode.api.exc import (
37 37 JSONRPCBaseError, JSONRPCError, JSONRPCForbidden, JSONRPCValidationError)
38 38 from rhodecode.apps._base import TemplateArgs
39 39 from rhodecode.lib.auth import AuthUser
40 40 from rhodecode.lib.base import get_ip_addr, attach_context_attributes
41 41 from rhodecode.lib.ext_json import json
42 42 from rhodecode.lib.utils2 import safe_str
43 43 from rhodecode.lib.plugins.utils import get_plugin_settings
44 44 from rhodecode.model.db import User, UserApiKeys
45 45
46 46 log = logging.getLogger(__name__)
47 47
48 48 DEFAULT_RENDERER = 'jsonrpc_renderer'
49 49 DEFAULT_URL = '/_admin/apiv2'
50 50
51 51
52 52 def find_methods(jsonrpc_methods, pattern):
53 53 matches = OrderedDict()
54 54 if not isinstance(pattern, (list, tuple)):
55 55 pattern = [pattern]
56 56
57 57 for single_pattern in pattern:
58 58 for method_name, method in jsonrpc_methods.items():
59 59 if fnmatch.fnmatch(method_name, single_pattern):
60 60 matches[method_name] = method
61 61 return matches
62 62
63 63
64 64 class ExtJsonRenderer(object):
65 65 """
66 66 Custom renderer that mkaes use of our ext_json lib
67 67
68 68 """
69 69
70 70 def __init__(self, serializer=json.dumps, **kw):
71 71 """ Any keyword arguments will be passed to the ``serializer``
72 72 function."""
73 73 self.serializer = serializer
74 74 self.kw = kw
75 75
76 76 def __call__(self, info):
77 77 """ Returns a plain JSON-encoded string with content-type
78 78 ``application/json``. The content-type may be overridden by
79 79 setting ``request.response.content_type``."""
80 80
81 81 def _render(value, system):
82 82 request = system.get('request')
83 83 if request is not None:
84 84 response = request.response
85 85 ct = response.content_type
86 86 if ct == response.default_content_type:
87 87 response.content_type = 'application/json'
88 88
89 89 return self.serializer(value, **self.kw)
90 90
91 91 return _render
92 92
93 93
94 94 def jsonrpc_response(request, result):
95 95 rpc_id = getattr(request, 'rpc_id', None)
96 96 response = request.response
97 97
98 98 # store content_type before render is called
99 99 ct = response.content_type
100 100
101 101 ret_value = ''
102 102 if rpc_id:
103 103 ret_value = {
104 104 'id': rpc_id,
105 105 'result': result,
106 106 'error': None,
107 107 }
108 108
109 109 # fetch deprecation warnings, and store it inside results
110 110 deprecation = getattr(request, 'rpc_deprecation', None)
111 111 if deprecation:
112 112 ret_value['DEPRECATION_WARNING'] = deprecation
113 113
114 114 raw_body = render(DEFAULT_RENDERER, ret_value, request=request)
115 115 response.body = safe_str(raw_body, response.charset)
116 116
117 117 if ct == response.default_content_type:
118 118 response.content_type = 'application/json'
119 119
120 120 return response
121 121
122 122
123 123 def jsonrpc_error(request, message, retid=None, code=None):
124 124 """
125 125 Generate a Response object with a JSON-RPC error body
126 126
127 127 :param code:
128 128 :param retid:
129 129 :param message:
130 130 """
131 131 err_dict = {'id': retid, 'result': None, 'error': message}
132 132 body = render(DEFAULT_RENDERER, err_dict, request=request).encode('utf-8')
133 133 return Response(
134 134 body=body,
135 135 status=code,
136 136 content_type='application/json'
137 137 )
138 138
139 139
140 140 def exception_view(exc, request):
141 141 rpc_id = getattr(request, 'rpc_id', None)
142 142
143 143 fault_message = 'undefined error'
144 144 if isinstance(exc, JSONRPCError):
145 fault_message = exc.message
145 fault_message = safe_str(exc.message)
146 146 log.debug('json-rpc error rpc_id:%s "%s"', rpc_id, fault_message)
147 147 elif isinstance(exc, JSONRPCValidationError):
148 148 colander_exc = exc.colander_exception
149 149 # TODO(marcink): think maybe of nicer way to serialize errors ?
150 150 fault_message = colander_exc.asdict()
151 log.debug('json-rpc error rpc_id:%s "%s"', rpc_id, fault_message)
151 log.debug('json-rpc colander error rpc_id:%s "%s"', rpc_id, fault_message)
152 152 elif isinstance(exc, JSONRPCForbidden):
153 153 fault_message = 'Access was denied to this resource.'
154 154 log.warning('json-rpc forbidden call rpc_id:%s "%s"', rpc_id, fault_message)
155 155 elif isinstance(exc, HTTPNotFound):
156 156 method = request.rpc_method
157 157 log.debug('json-rpc method `%s` not found in list of '
158 158 'api calls: %s, rpc_id:%s',
159 159 method, request.registry.jsonrpc_methods.keys(), rpc_id)
160 160
161 161 similar = 'none'
162 162 try:
163 163 similar_paterns = ['*{}*'.format(x) for x in method.split('_')]
164 164 similar_found = find_methods(
165 165 request.registry.jsonrpc_methods, similar_paterns)
166 166 similar = ', '.join(similar_found.keys()) or similar
167 167 except Exception:
168 168 # make the whole above block safe
169 169 pass
170 170
171 171 fault_message = "No such method: {}. Similar methods: {}".format(
172 172 method, similar)
173 173
174 174 return jsonrpc_error(request, fault_message, rpc_id)
175 175
176 176
177 177 def request_view(request):
178 178 """
179 179 Main request handling method. It handles all logic to call a specific
180 180 exposed method
181 181 """
182 182
183 183 # check if we can find this session using api_key, get_by_auth_token
184 184 # search not expired tokens only
185 185
186 186 try:
187 187 api_user = User.get_by_auth_token(request.rpc_api_key)
188 188
189 189 if api_user is None:
190 190 return jsonrpc_error(
191 191 request, retid=request.rpc_id, message='Invalid API KEY')
192 192
193 193 if not api_user.active:
194 194 return jsonrpc_error(
195 195 request, retid=request.rpc_id,
196 196 message='Request from this user not allowed')
197 197
198 198 # check if we are allowed to use this IP
199 199 auth_u = AuthUser(
200 200 api_user.user_id, request.rpc_api_key, ip_addr=request.rpc_ip_addr)
201 201 if not auth_u.ip_allowed:
202 202 return jsonrpc_error(
203 203 request, retid=request.rpc_id,
204 204 message='Request from IP:%s not allowed' % (
205 205 request.rpc_ip_addr,))
206 206 else:
207 207 log.info('Access for IP:%s allowed', request.rpc_ip_addr)
208 208
209 209 # register our auth-user
210 210 request.rpc_user = auth_u
211 211 request.environ['rc_auth_user_id'] = auth_u.user_id
212 212
213 213 # now check if token is valid for API
214 214 auth_token = request.rpc_api_key
215 215 token_match = api_user.authenticate_by_token(
216 216 auth_token, roles=[UserApiKeys.ROLE_API])
217 217 invalid_token = not token_match
218 218
219 219 log.debug('Checking if API KEY is valid with proper role')
220 220 if invalid_token:
221 221 return jsonrpc_error(
222 222 request, retid=request.rpc_id,
223 223 message='API KEY invalid or, has bad role for an API call')
224 224
225 225 except Exception:
226 226 log.exception('Error on API AUTH')
227 227 return jsonrpc_error(
228 228 request, retid=request.rpc_id, message='Invalid API KEY')
229 229
230 230 method = request.rpc_method
231 231 func = request.registry.jsonrpc_methods[method]
232 232
233 233 # now that we have a method, add request._req_params to
234 234 # self.kargs and dispatch control to WGIController
235 235 argspec = inspect.getargspec(func)
236 236 arglist = argspec[0]
237 237 defaults = map(type, argspec[3] or [])
238 238 default_empty = types.NotImplementedType
239 239
240 240 # kw arguments required by this method
241 241 func_kwargs = dict(itertools.izip_longest(
242 242 reversed(arglist), reversed(defaults), fillvalue=default_empty))
243 243
244 244 # This attribute will need to be first param of a method that uses
245 245 # api_key, which is translated to instance of user at that name
246 246 user_var = 'apiuser'
247 247 request_var = 'request'
248 248
249 249 for arg in [user_var, request_var]:
250 250 if arg not in arglist:
251 251 return jsonrpc_error(
252 252 request,
253 253 retid=request.rpc_id,
254 254 message='This method [%s] does not support '
255 255 'required parameter `%s`' % (func.__name__, arg))
256 256
257 257 # get our arglist and check if we provided them as args
258 258 for arg, default in func_kwargs.items():
259 259 if arg in [user_var, request_var]:
260 260 # user_var and request_var are pre-hardcoded parameters and we
261 261 # don't need to do any translation
262 262 continue
263 263
264 264 # skip the required param check if it's default value is
265 265 # NotImplementedType (default_empty)
266 266 if default == default_empty and arg not in request.rpc_params:
267 267 return jsonrpc_error(
268 268 request,
269 269 retid=request.rpc_id,
270 270 message=('Missing non optional `%s` arg in JSON DATA' % arg)
271 271 )
272 272
273 273 # sanitize extra passed arguments
274 274 for k in request.rpc_params.keys()[:]:
275 275 if k not in func_kwargs:
276 276 del request.rpc_params[k]
277 277
278 278 call_params = request.rpc_params
279 279 call_params.update({
280 280 'request': request,
281 281 'apiuser': auth_u
282 282 })
283 283
284 284 # register some common functions for usage
285 285 attach_context_attributes(
286 286 TemplateArgs(), request, request.rpc_user.user_id)
287 287
288 288 try:
289 289 ret_value = func(**call_params)
290 290 return jsonrpc_response(request, ret_value)
291 291 except JSONRPCBaseError:
292 292 raise
293 293 except Exception:
294 294 log.exception('Unhandled exception occurred on api call: %s', func)
295 295 return jsonrpc_error(request, retid=request.rpc_id,
296 296 message='Internal server error')
297 297
298 298
299 299 def setup_request(request):
300 300 """
301 301 Parse a JSON-RPC request body. It's used inside the predicates method
302 302 to validate and bootstrap requests for usage in rpc calls.
303 303
304 304 We need to raise JSONRPCError here if we want to return some errors back to
305 305 user.
306 306 """
307 307
308 308 log.debug('Executing setup request: %r', request)
309 309 request.rpc_ip_addr = get_ip_addr(request.environ)
310 310 # TODO(marcink): deprecate GET at some point
311 311 if request.method not in ['POST', 'GET']:
312 312 log.debug('unsupported request method "%s"', request.method)
313 313 raise JSONRPCError(
314 314 'unsupported request method "%s". Please use POST' % request.method)
315 315
316 316 if 'CONTENT_LENGTH' not in request.environ:
317 317 log.debug("No Content-Length")
318 318 raise JSONRPCError("Empty body, No Content-Length in request")
319 319
320 320 else:
321 321 length = request.environ['CONTENT_LENGTH']
322 322 log.debug('Content-Length: %s', length)
323 323
324 324 if length == 0:
325 325 log.debug("Content-Length is 0")
326 326 raise JSONRPCError("Content-Length is 0")
327 327
328 328 raw_body = request.body
329 329 try:
330 330 json_body = json.loads(raw_body)
331 331 except ValueError as e:
332 332 # catch JSON errors Here
333 333 raise JSONRPCError("JSON parse error ERR:%s RAW:%r" % (e, raw_body))
334 334
335 335 request.rpc_id = json_body.get('id')
336 336 request.rpc_method = json_body.get('method')
337 337
338 338 # check required base parameters
339 339 try:
340 340 api_key = json_body.get('api_key')
341 341 if not api_key:
342 342 api_key = json_body.get('auth_token')
343 343
344 344 if not api_key:
345 345 raise KeyError('api_key or auth_token')
346 346
347 347 # TODO(marcink): support passing in token in request header
348 348
349 349 request.rpc_api_key = api_key
350 350 request.rpc_id = json_body['id']
351 351 request.rpc_method = json_body['method']
352 352 request.rpc_params = json_body['args'] \
353 353 if isinstance(json_body['args'], dict) else {}
354 354
355 355 log.debug('method: %s, params: %s', request.rpc_method, request.rpc_params)
356 356 except KeyError as e:
357 357 raise JSONRPCError('Incorrect JSON data. Missing %s' % e)
358 358
359 359 log.debug('setup complete, now handling method:%s rpcid:%s',
360 360 request.rpc_method, request.rpc_id, )
361 361
362 362
363 363 class RoutePredicate(object):
364 364 def __init__(self, val, config):
365 365 self.val = val
366 366
367 367 def text(self):
368 368 return 'jsonrpc route = %s' % self.val
369 369
370 370 phash = text
371 371
372 372 def __call__(self, info, request):
373 373 if self.val:
374 374 # potentially setup and bootstrap our call
375 375 setup_request(request)
376 376
377 377 # Always return True so that even if it isn't a valid RPC it
378 378 # will fall through to the underlaying handlers like notfound_view
379 379 return True
380 380
381 381
382 382 class NotFoundPredicate(object):
383 383 def __init__(self, val, config):
384 384 self.val = val
385 385 self.methods = config.registry.jsonrpc_methods
386 386
387 387 def text(self):
388 388 return 'jsonrpc method not found = {}.'.format(self.val)
389 389
390 390 phash = text
391 391
392 392 def __call__(self, info, request):
393 393 return hasattr(request, 'rpc_method')
394 394
395 395
396 396 class MethodPredicate(object):
397 397 def __init__(self, val, config):
398 398 self.method = val
399 399
400 400 def text(self):
401 401 return 'jsonrpc method = %s' % self.method
402 402
403 403 phash = text
404 404
405 405 def __call__(self, context, request):
406 406 # we need to explicitly return False here, so pyramid doesn't try to
407 407 # execute our view directly. We need our main handler to execute things
408 408 return getattr(request, 'rpc_method') == self.method
409 409
410 410
411 411 def add_jsonrpc_method(config, view, **kwargs):
412 412 # pop the method name
413 413 method = kwargs.pop('method', None)
414 414
415 415 if method is None:
416 416 raise ConfigurationError(
417 417 'Cannot register a JSON-RPC method without specifying the '
418 418 '"method"')
419 419
420 420 # we define custom predicate, to enable to detect conflicting methods,
421 421 # those predicates are kind of "translation" from the decorator variables
422 422 # to internal predicates names
423 423
424 424 kwargs['jsonrpc_method'] = method
425 425
426 426 # register our view into global view store for validation
427 427 config.registry.jsonrpc_methods[method] = view
428 428
429 429 # we're using our main request_view handler, here, so each method
430 430 # has a unified handler for itself
431 431 config.add_view(request_view, route_name='apiv2', **kwargs)
432 432
433 433
434 434 class jsonrpc_method(object):
435 435 """
436 436 decorator that works similar to @add_view_config decorator,
437 437 but tailored for our JSON RPC
438 438 """
439 439
440 440 venusian = venusian # for testing injection
441 441
442 442 def __init__(self, method=None, **kwargs):
443 443 self.method = method
444 444 self.kwargs = kwargs
445 445
446 446 def __call__(self, wrapped):
447 447 kwargs = self.kwargs.copy()
448 448 kwargs['method'] = self.method or wrapped.__name__
449 449 depth = kwargs.pop('_depth', 0)
450 450
451 451 def callback(context, name, ob):
452 452 config = context.config.with_package(info.module)
453 453 config.add_jsonrpc_method(view=ob, **kwargs)
454 454
455 455 info = venusian.attach(wrapped, callback, category='pyramid',
456 456 depth=depth + 1)
457 457 if info.scope == 'class':
458 458 # ensure that attr is set if decorating a class method
459 459 kwargs.setdefault('attr', wrapped.__name__)
460 460
461 461 kwargs['_info'] = info.codeinfo # fbo action_method
462 462 return wrapped
463 463
464 464
465 465 class jsonrpc_deprecated_method(object):
466 466 """
467 467 Marks method as deprecated, adds log.warning, and inject special key to
468 468 the request variable to mark method as deprecated.
469 469 Also injects special docstring that extract_docs will catch to mark
470 470 method as deprecated.
471 471
472 472 :param use_method: specify which method should be used instead of
473 473 the decorated one
474 474
475 475 Use like::
476 476
477 477 @jsonrpc_method()
478 478 @jsonrpc_deprecated_method(use_method='new_func', deprecated_at_version='3.0.0')
479 479 def old_func(request, apiuser, arg1, arg2):
480 480 ...
481 481 """
482 482
483 483 def __init__(self, use_method, deprecated_at_version):
484 484 self.use_method = use_method
485 485 self.deprecated_at_version = deprecated_at_version
486 486 self.deprecated_msg = ''
487 487
488 488 def __call__(self, func):
489 489 self.deprecated_msg = 'Please use method `{method}` instead.'.format(
490 490 method=self.use_method)
491 491
492 492 docstring = """\n
493 493 .. deprecated:: {version}
494 494
495 495 {deprecation_message}
496 496
497 497 {original_docstring}
498 498 """
499 499 func.__doc__ = docstring.format(
500 500 version=self.deprecated_at_version,
501 501 deprecation_message=self.deprecated_msg,
502 502 original_docstring=func.__doc__)
503 503 return decorator.decorator(self.__wrapper, func)
504 504
505 505 def __wrapper(self, func, *fargs, **fkwargs):
506 506 log.warning('DEPRECATED API CALL on function %s, please '
507 507 'use `%s` instead', func, self.use_method)
508 508 # alter function docstring to mark as deprecated, this is picked up
509 509 # via fabric file that generates API DOC.
510 510 result = func(*fargs, **fkwargs)
511 511
512 512 request = fargs[0]
513 513 request.rpc_deprecation = 'DEPRECATED METHOD ' + self.deprecated_msg
514 514 return result
515 515
516 516
517 517 def includeme(config):
518 518 plugin_module = 'rhodecode.api'
519 519 plugin_settings = get_plugin_settings(
520 520 plugin_module, config.registry.settings)
521 521
522 522 if not hasattr(config.registry, 'jsonrpc_methods'):
523 523 config.registry.jsonrpc_methods = OrderedDict()
524 524
525 525 # match filter by given method only
526 526 config.add_view_predicate('jsonrpc_method', MethodPredicate)
527 527
528 528 config.add_renderer(DEFAULT_RENDERER, ExtJsonRenderer(
529 529 serializer=json.dumps, indent=4))
530 530 config.add_directive('add_jsonrpc_method', add_jsonrpc_method)
531 531
532 532 config.add_route_predicate(
533 533 'jsonrpc_call', RoutePredicate)
534 534
535 535 config.add_route(
536 536 'apiv2', plugin_settings.get('url', DEFAULT_URL), jsonrpc_call=True)
537 537
538 538 config.scan(plugin_module, ignore='rhodecode.api.tests')
539 539 # register some exception handling view
540 540 config.add_view(exception_view, context=JSONRPCBaseError)
541 541 config.add_view_predicate('jsonrpc_method_not_found', NotFoundPredicate)
542 542 config.add_notfound_view(exception_view, jsonrpc_method_not_found=True)
General Comments 0
You need to be logged in to leave comments. Login now