From 3e55d413eca530871e15178deab6849d25c425ab Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 28 Oct 2026 01:06:45 +1100 Subject: [PATCH] [report] Harden data access for reports (#13111) * [report] Harden data access for reports - Exclude sensitive data - Prevent database writes during report generation * Fix typo * Add regression test against model traversal * Add webhook endpoint and message to imports Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Remove some models from BLOCKED_MODELS * Revert READ_ONLY requirement --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/docs/report/helpers.md | 3 + src/backend/InvenTree/report/models.py | 22 ++-- src/backend/InvenTree/report/security.py | 107 ++++++++++++++++++ .../InvenTree/report/templatetags/report.py | 6 + src/backend/InvenTree/report/tests.py | 80 +++++++++++++ 5 files changed, 209 insertions(+), 9 deletions(-) create mode 100644 src/backend/InvenTree/report/security.py diff --git a/docs/docs/report/helpers.md b/docs/docs/report/helpers.md index 150ccfe9d3..863f314bed 100644 --- a/docs/docs/report/helpers.md +++ b/docs/docs/report/helpers.md @@ -216,6 +216,9 @@ The `filter_db_model` function allows for filtering of a database model based on show_docstring_description: false show_source: False +!!! warning "Restricted Data" + While a template renders, tables holding sensitive information - such as user sessions, API tokens, outbound emails, plugin settings and social / OAuth / MFA data - are blocked. Access raises a permission error. This is defence-in-depth only: it works on whole tables, and templates remain [trusted](../concepts/threat_model.md). + #### Example Generate a list of all active customers: diff --git a/src/backend/InvenTree/report/models.py b/src/backend/InvenTree/report/models.py index b8cde506e3..8ba02e69d1 100644 --- a/src/backend/InvenTree/report/models.py +++ b/src/backend/InvenTree/report/models.py @@ -35,6 +35,7 @@ from InvenTree.helpers_model import get_base_url from InvenTree.models import MetadataMixin from plugin import InvenTreePlugin, PluginMixinEnum from plugin.registry import registry +from report.security import restricted_db_access try: from weasyprint import HTML @@ -258,7 +259,9 @@ class ReportTemplateBase( def generate_filename(self, context, **kwargs) -> str: """Generate a filename for this report.""" template_string = Template(self.filename_pattern) - return template_string.render(Context(context)) + + with restricted_db_access(): + return template_string.render(Context(context)) def get_template_content(self) -> str: """Load template file content directly from storage backend. @@ -339,15 +342,16 @@ class ReportTemplateBase( if context is None: context = self.get_context(instance, **kwargs) - try: - return render_to_string(self.template_name, context) - except TemplateDoesNotExist: - content = self.get_template_content() - from django.template import engines + with restricted_db_access(): + try: + return render_to_string(self.template_name, context) + except TemplateDoesNotExist: + content = self.get_template_content() + from django.template import engines - engine = engines['django'] - template = engine.from_string(content) - return template.render(context) + engine = engines['django'] + template = engine.from_string(content) + return template.render(context) def render( self, instance: models.Model, context: Optional[dict] = None, **kwargs diff --git a/src/backend/InvenTree/report/security.py b/src/backend/InvenTree/report/security.py new file mode 100644 index 0000000000..b3530a60b4 --- /dev/null +++ b/src/backend/InvenTree/report/security.py @@ -0,0 +1,107 @@ +"""Hardening helpers for report / label template rendering. + +Templates are considered trusted (see the threat model), but rendering is +restricted as defence-in-depth: while a template renders, tables holding +credentials, sessions, tokens, or outbound mail cannot be queried - neither via +``filter_db_model`` nor by relation traversal. + +Note: this operates on whole tables. It cannot hide individual rows or +columns of tables that are legitimately needed by reports. +""" + +import re +from contextlib import ExitStack, contextmanager + +from django.apps import apps +from django.core.exceptions import PermissionDenied +from django.db import connections + +# Models which may never be accessed while rendering a template +BLOCKED_MODELS = [ + 'sessions.session', + 'common.emailmessage', + 'common.emailthread', + 'common.webhookendpoint', + 'common.webhookmessage', + 'users.apitoken', + 'authtoken.token', + 'socialaccount.socialtoken', + 'socialaccount.socialapp', + 'socialaccount.socialaccount', + 'oauth2_provider.accesstoken', + 'oauth2_provider.refreshtoken', + 'oauth2_provider.grant', + 'oauth2_provider.idtoken', + 'oauth2_provider.application', + 'mfa.authenticator', + 'usersessions.usersession', + 'account.emailaddress', + 'account.emailconfirmation', +] + +# Apps for which every model is blocked +BLOCKED_APPS = ['sessions', 'socialaccount', 'oauth2_provider', 'mfa', 'usersessions'] + +_WORD = re.compile(r'\w+') + + +class ReportAccessDenied(PermissionDenied): + """Raised when a template attempts to access restricted data.""" + + +def _blocked_model_set() -> set: + blocked = set() + + for label in BLOCKED_MODELS: + try: + blocked.add(apps.get_model(label)) + except (LookupError, ValueError): + continue + + for app_label in BLOCKED_APPS: + try: + blocked.update(apps.get_app_config(app_label).get_models()) + except LookupError: + continue + + return blocked + + +def blocked_tables() -> set: + """Return the set of lowercase DB table names which are blocked.""" + tables = set() + + for model in _blocked_model_set(): + tables.add(model._meta.db_table.lower()) + + # Auto-created M2M tables + for field in model._meta.local_many_to_many: + tables.add(field.m2m_db_table().lower()) + + return tables + + +def is_model_blocked(model) -> bool: + """Check if the provided model class is blocked for template access.""" + return model in _blocked_model_set() + + +def _guard(tables: set): + def wrapper(execute, sql, params, many, context): + if tables.intersection(w.lower() for w in _WORD.findall(sql)): + raise ReportAccessDenied('Access to this data is not allowed in templates') + + return execute(sql, params, many, context) + + return wrapper + + +@contextmanager +def restricted_db_access(): + """Context manager: prevent database access to restricted tables.""" + guard = _guard(blocked_tables()) + + with ExitStack() as stack: + for conn in connections.all(): + stack.enter_context(conn.execute_wrapper(guard)) + yield diff --git a/src/backend/InvenTree/report/templatetags/report.py b/src/backend/InvenTree/report/templatetags/report.py index ad015b4784..a3f615ba48 100644 --- a/src/backend/InvenTree/report/templatetags/report.py +++ b/src/backend/InvenTree/report/templatetags/report.py @@ -44,6 +44,7 @@ import report.helpers from common.settings import get_global_setting from company.models import Company from part.models import Part +from report.security import ReportAccessDenied, is_model_blocked register = template.Library() @@ -143,6 +144,11 @@ def filter_db_model(model_name: str, **kwargs) -> Optional[QuerySet]: if model is None: return None + if is_model_blocked(model): + raise ReportAccessDenied( + 'Access to this model is not allowed in report templates' + ) + queryset = model.objects.all() return filter_queryset(queryset, **kwargs) diff --git a/src/backend/InvenTree/report/tests.py b/src/backend/InvenTree/report/tests.py index d7286cbd03..d005d82493 100644 --- a/src/backend/InvenTree/report/tests.py +++ b/src/backend/InvenTree/report/tests.py @@ -1710,3 +1710,83 @@ class S3ReportTemplateTest(TestCase): 'report/custom_snippet.html', {'username': 'testuser'} ) self.assertEqual(result, 'User: testuser') + + +class ReportSecurityTest(InvenTreeAPITestCase): + """Tests for restricted database access while rendering templates.""" + + fixtures = ['category', 'part', 'location'] + + def render(self, source): + """Render a template string.""" + from django.template import Context, Template + + from report.security import restricted_db_access + + with restricted_db_access(): + return Template('{% load report %}' + source).render(Context({})) + + def test_blocked_models(self): + """Blocked models cannot be queried via the template tag.""" + from django.core.exceptions import PermissionDenied + + for label in ['sessions.session', 'common.emailmessage', 'users.apitoken']: + with self.assertRaises(PermissionDenied): + self.render(f"{{% filter_db_model '{label}' as q %}}{{{{ q|length }}}}") + + def test_traversal_blocked(self): + """Raw queries on blocked tables are rejected, even without the tag.""" + from django.contrib.sessions.models import Session + from django.core.exceptions import PermissionDenied + + from report.security import restricted_db_access + + with restricted_db_access(), self.assertRaises(PermissionDenied): + list(Session.objects.all()) + + def test_normal_access(self): + """Regular models still work.""" + out = self.render("{% filter_db_model 'part.part' as q %}{{ q|length }}") + self.assertGreater(int(out), 0) + + +class ReportTraversalSecurityTest(InvenTreeAPITestCase): + """Restricted models cannot be reached by dot-notation traversal.""" + + fixtures = ['category', 'part', 'location', 'stock', 'build'] + + def test_traversal_to_api_tokens(self): + """Traversing build.issued_by.api_tokens must be denied.""" + from django.core.exceptions import PermissionDenied + from django.template import Context, Template + + from build.models import Build + from users.models import ApiToken + + build = Build.objects.first() + build.issued_by = self.user + build.save() + + ApiToken.objects.create(user=self.user, name='secret-token') + + source = ( + '{% for token in build.issued_by.api_tokens.all %}' + '{{ token.key }}|{{ token.name }}{% endfor %}' + ) + context = {'build': build} + + # Without the guard the token is readable (sanity check) + out = Template(source).render(Context(context)) + self.assertIn('secret-token', out) + + # Rendering through the report machinery is denied + template = ReportTemplate.objects.create( + name='traversal', + description='traversal test', + model_type='build', + template=ContentFile(source, 'traversal.html'), + enabled=True, + ) + + with self.assertRaises(PermissionDenied): + template.render_as_string(build, context=context)