feat(whitelisted): Runtime typing hints validation

- Run type validations if annotations exist for whitelisted functions
- Run validations only on function calls in presense of frappe.local.request

In action:

```bash
> curl -H 'Content-Type: application/json' 'http://photos:8000/api/method/frappe.handler.download_file' -d '{"file_url": ["!=", "gavin.jpg"]}'
```

Note: This ignores stringified or ForwardRef types. If you want types to
be validated make sure they are not imported under `if TYPE_CHECKING`
blocks
This commit is contained in:
Gavin D'souza 2022-11-23 15:52:42 +05:30
parent ccbc833c6c
commit 3fd74afa47
3 changed files with 71 additions and 19 deletions

View file

@ -716,25 +716,20 @@ xss_safe_methods = []
allowed_http_methods_for_whitelisted_func = {}
def validate_argument_types(func):
return func
from pydantic import validate_arguments as pyd_validator
def apply_validate_argument_types_wrapper(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
"""Validate argument types of whitelisted functions.
def validator(*args, **kwargs):
return pyd_validator(func)(*args, **kwargs)
:param args: Function arguments.
:param kwargs: Function keyword arguments."""
from frappe.utils.typing_validations import validate_argument_types
import sys
if getattr(local, "request", None):
validate_argument_types(func, args, kwargs)
return func(*args, **kwargs)
# from frappe.model.document import Document
# localns = {
# "Document": Document,
# }
try:
return validator(func)
except NameError:
sys.stderr.write(f"{func} has unsupported type annotations")
return func
return wrapper
def whitelist(allow_guest=False, xss_safe=False, methods=None):
@ -762,8 +757,10 @@ def whitelist(allow_guest=False, xss_safe=False, methods=None):
# this is needed because functions can be compared, but not methods
method = None
if hasattr(fn, "__func__"):
method = fn
method = apply_validate_argument_types_wrapper(fn)
fn = method.__func__
else:
fn = apply_validate_argument_types_wrapper(fn)
whitelisted.append(fn)
allowed_http_methods_for_whitelisted_func[fn] = methods
@ -774,7 +771,7 @@ def whitelist(allow_guest=False, xss_safe=False, methods=None):
if xss_safe:
xss_safe_methods.append(fn)
return validate_argument_types(method or fn)
return method or fn
return innerfn

View file

@ -0,0 +1,56 @@
from inspect import isclass
from typing import Callable, ForwardRef, Sequence
def qualified_name(obj) -> str:
"""
Return the qualified name (e.g. package.module.Type) for the given object.
Builtins and types from the :mod:`typing` package get special treatment by having the module
name stripped from the generated name.
"""
discovered_type = obj if isclass(obj) else type(obj)
module, qualname = discovered_type.__module__, discovered_type.__qualname__
if module == "types":
return obj
elif module in {"typing", "builtins"}:
return qualname
else:
return f"{module}.{qualname}"
def validate_argument_types(func: Callable, args: tuple, kwargs: dict):
"""
Validate the types of the arguments passed to a function with the type annotations
defined on the function.
"""
if annotations := func.__annotations__:
# generate kwargs dict from args
arg_names = func.__code__.co_varnames[: func.__code__.co_argcount]
arg_values = args or func.__defaults__ or []
prepared_args = dict(zip(arg_names, arg_values))
prepared_args.update(kwargs)
# check if the argument types are correct
for current_arg, current_arg_type in annotations.items():
if current_arg not in prepared_args:
continue
current_arg_value = prepared_args[current_arg]
# if the type is a ForwardRef or str, ignore it
if isinstance(current_arg_type, (ForwardRef, str)):
continue
if isinstance(current_arg_type, Sequence):
current_arg_type = tuple(
x for x in current_arg_type.__args__ if not isinstance(x, (ForwardRef, str))
)
if not isinstance(current_arg_value, current_arg_type):
raise TypeError(
f"Argument '{current_arg}' must be of type '{qualified_name(current_arg_type)}' but got '{qualified_name(current_arg_value)}'"
)

View file

@ -52,7 +52,6 @@ dependencies = [
"pyOpenSSL~=22.1.0",
"pycryptodome~=3.10.1",
"pyotp~=2.6.0",
"pydantic~=1.10.2",
"python-dateutil~=2.8.1",
"pytz==2022.1",
"rauth~=0.7.3",