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:
parent
ccbc833c6c
commit
3fd74afa47
3 changed files with 71 additions and 19 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
56
frappe/utils/typing_validations.py
Normal file
56
frappe/utils/typing_validations.py
Normal 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)}'"
|
||||
)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue