[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>
This commit is contained in:
OliverandCopilot Autofix powered by AI authored and GitHub committed 2026-10-28 01:06:45 +11:00
1 parent 4f80cda960
commit 3e55d413ec
5 files changed
+209 -9

No files matched your search

+3
View File
@@ -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:
+13 -9
View File
@@ -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
+107
View File
@@ -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
@@ -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)
+80
View File
@@ -1710,3 +1710,83 @@ class S3ReportTemplateTest(TestCase):
'report/custom_snippet.html', {'username': 'testuser'}
)
self.assertEqual(result, '<span>User: testuser</span>')
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)