Skip to content

Commit

Permalink
Refs #31949 -- Made @sensitive_variables/sensitive_post_parameters de…
Browse files Browse the repository at this point in the history
…corators to work with async functions.

Co-authored-by: Mariusz Felisiak <[email protected]>
  • Loading branch information
bigfootjon and felixxm committed Jun 23, 2023
1 parent f8092ee commit 38e391e
Show file tree
Hide file tree
Showing 6 changed files with 302 additions and 37 deletions.
46 changes: 32 additions & 14 deletions django/views/debug.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import functools
import inspect
import itertools
import re
import sys
Expand All @@ -17,6 +18,7 @@
from django.utils.module_loading import import_string
from django.utils.regex_helper import _lazy_re_compile
from django.utils.version import PY311, get_docs_version
from django.views.decorators.debug import coroutine_functions_to_sensitive_variables

# Minimal Django templates engine to render the error templates
# regardless of the project's TEMPLATES setting. Templates are
Expand Down Expand Up @@ -239,21 +241,37 @@ def get_traceback_frame_variables(self, request, tb_frame):
Replace the values of variables marked as sensitive with
stars (*********).
"""
# Loop through the frame's callers to see if the sensitive_variables
# decorator was used.
current_frame = tb_frame.f_back
sensitive_variables = None
while current_frame is not None:
if (
current_frame.f_code.co_name == "sensitive_variables_wrapper"
and "sensitive_variables_wrapper" in current_frame.f_locals
):
# The sensitive_variables decorator was used, so we take note
# of the sensitive variables' names.
wrapper = current_frame.f_locals["sensitive_variables_wrapper"]
sensitive_variables = getattr(wrapper, "sensitive_variables", None)
break
current_frame = current_frame.f_back

# Coroutines don't have a proper `f_back` so they need to be inspected
# separately. Handle this by stashing the registered sensitive
# variables in a global dict indexed by `hash(file_path:line_number)`.
if (
tb_frame.f_code.co_flags & inspect.CO_COROUTINE != 0
and tb_frame.f_code.co_name != "sensitive_variables_wrapper"
):
key = hash(
f"{tb_frame.f_code.co_filename}:{tb_frame.f_code.co_firstlineno}"
)
sensitive_variables = coroutine_functions_to_sensitive_variables.get(
key, None
)

if sensitive_variables is None:
# Loop through the frame's callers to see if the
# sensitive_variables decorator was used.
current_frame = tb_frame
while current_frame is not None:
if (
current_frame.f_code.co_name == "sensitive_variables_wrapper"
and "sensitive_variables_wrapper" in current_frame.f_locals
):
# The sensitive_variables decorator was used, so take note
# of the sensitive variables' names.
wrapper = current_frame.f_locals["sensitive_variables_wrapper"]
sensitive_variables = getattr(wrapper, "sensitive_variables", None)
break
current_frame = current_frame.f_back

cleansed = {}
if self.is_active(request) and sensitive_variables:
Expand Down
88 changes: 70 additions & 18 deletions django/views/decorators/debug.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import inspect
from functools import wraps

from asgiref.sync import iscoroutinefunction

from django.http import HttpRequest

coroutine_functions_to_sensitive_variables = {}


def sensitive_variables(*variables):
"""
Expand Down Expand Up @@ -33,13 +38,42 @@ def my_function()
)

def decorator(func):
@wraps(func)
def sensitive_variables_wrapper(*func_args, **func_kwargs):
if iscoroutinefunction(func):

@wraps(func)
async def sensitive_variables_wrapper(*func_args, **func_kwargs):
return await func(*func_args, **func_kwargs)

wrapped_func = func
while getattr(wrapped_func, "__wrapped__", None) is not None:
wrapped_func = wrapped_func.__wrapped__

try:
file_path = inspect.getfile(wrapped_func)
_, first_file_line = inspect.getsourcelines(wrapped_func)
except TypeError: # Raises for builtins or native functions.
raise ValueError(
f"{func.__name__} cannot safely be wrapped by "
"@sensitive_variables, make it either non-async or defined in a "
"Python file (not a builtin or from a native extension)."
)
else:
key = hash(f"{file_path}:{first_file_line}")

if variables:
sensitive_variables_wrapper.sensitive_variables = variables
coroutine_functions_to_sensitive_variables[key] = variables
else:
sensitive_variables_wrapper.sensitive_variables = "__ALL__"
return func(*func_args, **func_kwargs)
coroutine_functions_to_sensitive_variables[key] = "__ALL__"

else:

@wraps(func)
def sensitive_variables_wrapper(*func_args, **func_kwargs):
if variables:
sensitive_variables_wrapper.sensitive_variables = variables
else:
sensitive_variables_wrapper.sensitive_variables = "__ALL__"
return func(*func_args, **func_kwargs)

return sensitive_variables_wrapper

Expand Down Expand Up @@ -77,19 +111,37 @@ def my_view(request)
)

def decorator(view):
@wraps(view)
def sensitive_post_parameters_wrapper(request, *args, **kwargs):
if not isinstance(request, HttpRequest):
raise TypeError(
"sensitive_post_parameters didn't receive an HttpRequest "
"object. If you are decorating a classmethod, make sure "
"to use @method_decorator."
)
if parameters:
request.sensitive_post_parameters = parameters
else:
request.sensitive_post_parameters = "__ALL__"
return view(request, *args, **kwargs)
if iscoroutinefunction(view):

@wraps(view)
async def sensitive_post_parameters_wrapper(request, *args, **kwargs):
if not isinstance(request, HttpRequest):
raise TypeError(
"sensitive_post_parameters didn't receive an HttpRequest "
"object. If you are decorating a classmethod, make sure to use "
"@method_decorator."
)
if parameters:
request.sensitive_post_parameters = parameters
else:
request.sensitive_post_parameters = "__ALL__"
return await view(request, *args, **kwargs)

else:

@wraps(view)
def sensitive_post_parameters_wrapper(request, *args, **kwargs):
if not isinstance(request, HttpRequest):
raise TypeError(
"sensitive_post_parameters didn't receive an HttpRequest "
"object. If you are decorating a classmethod, make sure to use "
"@method_decorator."
)
if parameters:
request.sensitive_post_parameters = parameters
else:
request.sensitive_post_parameters = "__ALL__"
return view(request, *args, **kwargs)

return sensitive_post_parameters_wrapper

Expand Down
8 changes: 8 additions & 0 deletions docs/howto/error-reporting.txt
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,10 @@ filtered out of error reports in a production environment (that is, where
exception reporting, and consider implementing a :ref:`custom filter
<custom-error-reports>` if necessary.

.. versionchanged:: 5.0

Support for wrapping ``async`` functions was added.

.. function:: sensitive_post_parameters(*parameters)

If one of your views receives an :class:`~django.http.HttpRequest` object
Expand Down Expand Up @@ -245,6 +249,10 @@ filtered out of error reports in a production environment (that is, where
``user_change_password`` in the ``auth`` admin) to prevent the leaking of
sensitive information such as user passwords.

.. versionchanged:: 5.0

Support for wrapping ``async`` functions was added.

.. _custom-error-reports:

Custom error reports
Expand Down
6 changes: 5 additions & 1 deletion docs/releases/5.0.txt
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ Decorators
* :func:`~django.views.decorators.cache.cache_control`
* :func:`~django.views.decorators.cache.never_cache`
* :func:`~django.views.decorators.common.no_append_slash`
* :func:`~django.views.decorators.debug.sensitive_variables`
* :func:`~django.views.decorators.debug.sensitive_post_parameters`
* ``xframe_options_deny()``
* ``xframe_options_sameorigin()``
* ``xframe_options_exempt()``
Expand All @@ -253,7 +255,9 @@ Email
Error Reporting
~~~~~~~~~~~~~~~

* ...
* :func:`~django.views.decorators.debug.sensitive_variables` and
:func:`~django.views.decorators.debug.sensitive_post_parameters` can now be
used with asynchronous functions.

File Storage
~~~~~~~~~~~~
Expand Down
108 changes: 104 additions & 4 deletions tests/view_tests/tests/test_debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from pathlib import Path
from unittest import mock, skipIf, skipUnless

from asgiref.sync import async_to_sync, iscoroutinefunction

from django.core import mail
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db import DatabaseError, connection
Expand Down Expand Up @@ -39,6 +41,10 @@
from django.views.decorators.debug import sensitive_post_parameters, sensitive_variables

from ..views import (
async_sensitive_method_view,
async_sensitive_method_view_nested,
async_sensitive_view,
async_sensitive_view_nested,
custom_exception_reporter_filter_view,
index_page,
multivalue_dict_key_error,
Expand Down Expand Up @@ -1351,7 +1357,10 @@ def verify_unsafe_response(
Asserts that potentially sensitive info are displayed in the response.
"""
request = self.rf.post("/some_url/", self.breakfast_data)
response = view(request)
if iscoroutinefunction(view):
response = async_to_sync(view)(request)
else:
response = view(request)
if check_for_vars:
# All variables are shown.
self.assertContains(response, "cooked_eggs", status_code=500)
Expand All @@ -1371,7 +1380,10 @@ def verify_safe_response(
Asserts that certain sensitive info are not displayed in the response.
"""
request = self.rf.post("/some_url/", self.breakfast_data)
response = view(request)
if iscoroutinefunction(view):
response = async_to_sync(view)(request)
else:
response = view(request)
if check_for_vars:
# Non-sensitive variable's name and value are shown.
self.assertContains(response, "cooked_eggs", status_code=500)
Expand Down Expand Up @@ -1418,7 +1430,10 @@ def verify_unsafe_email(self, view, check_for_POST_params=True):
with self.settings(ADMINS=[("Admin", "[email protected]")]):
mail.outbox = [] # Empty outbox
request = self.rf.post("/some_url/", self.breakfast_data)
view(request)
if iscoroutinefunction(view):
async_to_sync(view)(request)
else:
view(request)
self.assertEqual(len(mail.outbox), 1)
email = mail.outbox[0]

Expand Down Expand Up @@ -1451,7 +1466,10 @@ def verify_safe_email(self, view, check_for_POST_params=True):
with self.settings(ADMINS=[("Admin", "[email protected]")]):
mail.outbox = [] # Empty outbox
request = self.rf.post("/some_url/", self.breakfast_data)
view(request)
if iscoroutinefunction(view):
async_to_sync(view)(request)
else:
view(request)
self.assertEqual(len(mail.outbox), 1)
email = mail.outbox[0]

Expand Down Expand Up @@ -1543,6 +1561,24 @@ def test_sensitive_request(self):
self.verify_safe_response(sensitive_view)
self.verify_safe_email(sensitive_view)

def test_async_sensitive_request(self):
with self.settings(DEBUG=True):
self.verify_unsafe_response(async_sensitive_view)
self.verify_unsafe_email(async_sensitive_view)

with self.settings(DEBUG=False):
self.verify_safe_response(async_sensitive_view)
self.verify_safe_email(async_sensitive_view)

def test_async_sensitive_nested_request(self):
with self.settings(DEBUG=True):
self.verify_unsafe_response(async_sensitive_view_nested)
self.verify_unsafe_email(async_sensitive_view_nested)

with self.settings(DEBUG=False):
self.verify_safe_response(async_sensitive_view_nested)
self.verify_safe_email(async_sensitive_view_nested)

def test_paranoid_request(self):
"""
No POST parameters and frame variables can be seen in the
Expand Down Expand Up @@ -1598,6 +1634,46 @@ def test_sensitive_method(self):
)
self.verify_safe_email(sensitive_method_view, check_for_POST_params=False)

def test_async_sensitive_method(self):
"""
The sensitive_variables decorator works with async object methods.
"""
with self.settings(DEBUG=True):
self.verify_unsafe_response(
async_sensitive_method_view, check_for_POST_params=False
)
self.verify_unsafe_email(
async_sensitive_method_view, check_for_POST_params=False
)

with self.settings(DEBUG=False):
self.verify_safe_response(
async_sensitive_method_view, check_for_POST_params=False
)
self.verify_safe_email(
async_sensitive_method_view, check_for_POST_params=False
)

def test_async_sensitive_method_nested(self):
"""
The sensitive_variables decorator works with async object methods.
"""
with self.settings(DEBUG=True):
self.verify_unsafe_response(
async_sensitive_method_view_nested, check_for_POST_params=False
)
self.verify_unsafe_email(
async_sensitive_method_view_nested, check_for_POST_params=False
)

with self.settings(DEBUG=False):
self.verify_safe_response(
async_sensitive_method_view_nested, check_for_POST_params=False
)
self.verify_safe_email(
async_sensitive_method_view_nested, check_for_POST_params=False
)

def test_sensitive_function_arguments(self):
"""
Sensitive variables don't leak in the sensitive_variables decorator's
Expand Down Expand Up @@ -1890,6 +1966,30 @@ def test_sensitive_request(self):
with self.settings(DEBUG=False):
self.verify_safe_response(sensitive_view, check_for_vars=False)

def test_async_sensitive_request(self):
"""
Sensitive POST parameters cannot be seen in the default
error reports for sensitive requests.
"""
with self.settings(DEBUG=True):
self.verify_unsafe_response(async_sensitive_view, check_for_vars=False)

with self.settings(DEBUG=False):
self.verify_safe_response(async_sensitive_view, check_for_vars=False)

def test_async_sensitive_request_nested(self):
"""
Sensitive POST parameters cannot be seen in the default
error reports for sensitive requests.
"""
with self.settings(DEBUG=True):
self.verify_unsafe_response(
async_sensitive_view_nested, check_for_vars=False
)

with self.settings(DEBUG=False):
self.verify_safe_response(async_sensitive_view_nested, check_for_vars=False)

def test_paranoid_request(self):
"""
No POST parameters can be seen in the default error reports
Expand Down
Loading

0 comments on commit 38e391e

Please sign in to comment.