mirror of
https://github.com/inventree/InvenTree.git
synced 2026-11-11 03:18:03 +00:00
[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:
1 parent
4f80cda960
commit
3e55d413ec
5 files changed
+209
-9
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user