mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-05 22:52:33 +00:00
Improve security posture of PDF reporting (#12160)
* Add custom URL fetcher for PDF rendering * Fix for report helper functions * Use new fetcher * Additional unit tests * Add new setting to control remote URL fetching * validate URLs against SSRF * Add global setting to disable URL fetching entirely * Update docs * Fix capitalization * Fix logging backend * Update CHANGELOG
This commit is contained in:
@@ -677,6 +677,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'REPORT_FETCH_URLS': {
|
||||
'name': _('Report URL Fetching'),
|
||||
'description': _('Allow fetching of remote URLs when generating reports'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'REPORT_LOG_ERRORS': {
|
||||
'name': _('Log Report Errors'),
|
||||
'description': _('Log errors which occur when generating reports'),
|
||||
|
||||
@@ -15,6 +15,7 @@ from common.models import DataOutput
|
||||
from InvenTree.helpers import str2bool
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.mixins import LabelPrintingMixin, SettingsMixin
|
||||
from report.fetcher import InvenTreeURLFetcher
|
||||
from report.models import LabelTemplate
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
@@ -168,7 +169,7 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug
|
||||
generated_file = ContentFile(html_data, 'labels.html')
|
||||
else:
|
||||
# Render HTML to PDF
|
||||
html = weasyprint.HTML(string=html_data)
|
||||
html = weasyprint.HTML(string=html_data, url_fetcher=InvenTreeURLFetcher())
|
||||
document = html.render().write_pdf()
|
||||
generated_file = ContentFile(document, 'labels.pdf')
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""WeasyPrint URL fetcher with security restrictions for report generation."""
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import structlog
|
||||
from weasyprint.urls import URLFetcher
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
|
||||
|
||||
class InvenTreeURLFetcher(URLFetcher):
|
||||
"""WeasyPrint URL fetcher restricted to safe origins."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Disable redirect following so a same-origin URL cannot be used for SSRF."""
|
||||
kwargs.setdefault('allow_redirects', False)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def fetch(self, url, headers=None):
|
||||
"""Validate *url* before delegating to the parent fetcher."""
|
||||
parsed = urlparse(url)
|
||||
scheme = parsed.scheme.lower()
|
||||
|
||||
if scheme in ('data', 'http', 'https'):
|
||||
self._validate_http_url(url, parsed)
|
||||
return super().fetch(url, headers)
|
||||
|
||||
if scheme == 'file':
|
||||
logger.warning("InvenTreeURLFetcher: blocked file:// URL: '%s'", url)
|
||||
raise ValueError(
|
||||
f'file:// URLs are not permitted in report templates: {url}'
|
||||
)
|
||||
|
||||
raise ValueError(f"URL scheme '{scheme}' is not permitted in report templates")
|
||||
|
||||
def _validate_http_url(self, url: str, parsed) -> None:
|
||||
"""Raise if HTTP/HTTPS fetching is disabled or the URL is an SSRF risk."""
|
||||
from common.settings import get_global_setting
|
||||
from InvenTree.helpers_model import validate_url_no_ssrf
|
||||
|
||||
if not parsed.netloc:
|
||||
# data: URIs — self-contained, no network access required.
|
||||
return
|
||||
|
||||
if not get_global_setting('REPORT_FETCH_URLS', cache=False):
|
||||
logger.warning(
|
||||
"InvenTreeURLFetcher: blocked URL '%s': remote fetching is disabled (REPORT_FETCH_URLS=False)",
|
||||
url,
|
||||
)
|
||||
raise ValueError(
|
||||
f'Remote URL fetching is disabled in report templates: {url}'
|
||||
)
|
||||
|
||||
try:
|
||||
validate_url_no_ssrf(url)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"InvenTreeURLFetcher: blocked URL '%s': resolves to a private or reserved address",
|
||||
url,
|
||||
)
|
||||
raise
|
||||
@@ -36,6 +36,8 @@ from plugin.registry import registry
|
||||
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
|
||||
from report.fetcher import InvenTreeURLFetcher
|
||||
except OSError as err: # pragma: no cover
|
||||
print(f'OSError: {err}')
|
||||
print("Unable to import 'weasyprint' module.")
|
||||
@@ -277,7 +279,9 @@ class ReportTemplateBase(
|
||||
bytes: PDF data
|
||||
"""
|
||||
html = self.render_as_string(instance, context=context, **kwargs)
|
||||
pdf = HTML(string=html).write_pdf(pdf_forms=True)
|
||||
pdf = HTML(string=html, url_fetcher=InvenTreeURLFetcher()).write_pdf(
|
||||
pdf_forms=True
|
||||
)
|
||||
|
||||
return pdf
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from io import BytesIO
|
||||
@@ -288,13 +289,21 @@ def asset(filename: str, raise_error: bool = False) -> str | None:
|
||||
else:
|
||||
return None
|
||||
|
||||
# In debug mode, return a web URL to the asset file (rather than a local file path)
|
||||
# In debug mode, return a web URL to the asset file (rather than encoded data)
|
||||
if get_global_setting('REPORT_DEBUG_MODE', cache=False):
|
||||
return default_storage.url(str(full_path))
|
||||
|
||||
storage_path = default_storage.path(str(full_path))
|
||||
file_data = get_media_file_contents(full_path, raise_error=raise_error)
|
||||
|
||||
return f'file://{storage_path}'
|
||||
if not file_data:
|
||||
return None
|
||||
|
||||
mime_type, _encoding = mimetypes.guess_type(str(filename))
|
||||
if not mime_type:
|
||||
mime_type = 'application/octet-stream'
|
||||
|
||||
encoded = base64.b64encode(file_data).decode('ascii')
|
||||
return f'data:{mime_type};base64,{encoded}'
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
|
||||
@@ -88,7 +88,9 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
|
||||
|
||||
self.debug_mode(False)
|
||||
asset = report_tags.asset('test.txt')
|
||||
self.assertEqual(asset, f'file://{settings.MEDIA_ROOT}/report/assets/test.txt')
|
||||
# Non-debug mode returns a base64 data: URI (no file:// URLs)
|
||||
self.assertIsNotNone(asset)
|
||||
self.assertTrue(asset.startswith('data:text/plain;base64,'))
|
||||
|
||||
# Test for attempted path traversal
|
||||
with self.assertRaises(ValidationError):
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import os
|
||||
from io import StringIO
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from pypdf import PdfReader
|
||||
@@ -871,3 +873,203 @@ class AdminTest(AdminTestCase):
|
||||
def test_admin(self):
|
||||
"""Test the admin URL."""
|
||||
self.helper(model=ReportTemplate)
|
||||
|
||||
|
||||
class URLFetcherE2ETest(ReportTest):
|
||||
"""End-to-end test: the URL fetcher blocks malicious URLs during a real PDF render.
|
||||
|
||||
Extends ReportTest so that fixtures, auth, and default report templates are all
|
||||
available. The print task runs synchronously in test mode (no workers), so the
|
||||
render completes inline and log output is captured within the same request.
|
||||
"""
|
||||
|
||||
def test_file_url_blocked_in_render(self):
|
||||
"""A template embedding a file:// URL must still produce a PDF, but the URL must be blocked and logged."""
|
||||
from io import StringIO
|
||||
|
||||
# Upload a minimal report template that embeds a malicious file:// reference.
|
||||
html = (
|
||||
'<html><body>'
|
||||
'<img src="file:///etc/passwd">'
|
||||
'<p>Security test content</p>'
|
||||
'</body></html>'
|
||||
)
|
||||
template_io = StringIO(html)
|
||||
template_io.name = 'security_test_template.html'
|
||||
|
||||
response = self.post(
|
||||
reverse('api-report-template-list'),
|
||||
data={
|
||||
'name': 'Security Test',
|
||||
'description': 'Tests that file:// URLs are blocked during rendering',
|
||||
'template': template_io,
|
||||
'model_type': 'stockitem',
|
||||
},
|
||||
format=None,
|
||||
expected_code=201,
|
||||
)
|
||||
template_pk = response.data['pk']
|
||||
|
||||
item = StockItem.objects.first()
|
||||
self.assertIsNotNone(item)
|
||||
|
||||
# Render the template. WeasyPrint catches the ValueError from our fetcher and
|
||||
# continues, so the PDF is still generated — the blocked resource is just skipped.
|
||||
with self.assertLogs('inventree', level='WARNING') as captured:
|
||||
response = self.post(
|
||||
reverse('api-report-print'),
|
||||
{'template': template_pk, 'items': [item.pk]},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
# A PDF output should have been produced despite the blocked resource.
|
||||
self.assertTrue(response.data['output'].endswith('.pdf'))
|
||||
|
||||
# The fetcher must have logged a warning identifying the blocked URL.
|
||||
blocked_warnings = [
|
||||
msg
|
||||
for msg in captured.output
|
||||
if 'blocked file://' in msg and '/etc/passwd' in msg
|
||||
]
|
||||
self.assertTrue(
|
||||
blocked_warnings, 'Expected a blocked file:// warning in the log output'
|
||||
)
|
||||
|
||||
def test_ssrf_url_blocked_in_render(self):
|
||||
"""A template embedding an HTTP URL to a private/reserved address must be blocked and logged."""
|
||||
from io import StringIO
|
||||
|
||||
# 127.0.0.1 is loopback — validate_url_no_ssrf rejects it regardless of port.
|
||||
html = (
|
||||
'<html><body>'
|
||||
'<img src="http://127.0.0.1/ssrf-probe">'
|
||||
'<p>Security test content</p>'
|
||||
'</body></html>'
|
||||
)
|
||||
template_io = StringIO(html)
|
||||
template_io.name = 'ssrf_test_template.html'
|
||||
|
||||
response = self.post(
|
||||
reverse('api-report-template-list'),
|
||||
data={
|
||||
'name': 'SSRF Test',
|
||||
'description': 'Tests that SSRF URLs are blocked during rendering',
|
||||
'template': template_io,
|
||||
'model_type': 'stockitem',
|
||||
},
|
||||
format=None,
|
||||
expected_code=201,
|
||||
)
|
||||
template_pk = response.data['pk']
|
||||
|
||||
item = StockItem.objects.first()
|
||||
self.assertIsNotNone(item)
|
||||
|
||||
# Render the template. WeasyPrint catches the ValueError from our fetcher and
|
||||
# continues, so the PDF is still generated — the blocked resource is just skipped.
|
||||
with self.assertLogs('inventree', level='WARNING') as captured:
|
||||
response = self.post(
|
||||
reverse('api-report-print'),
|
||||
{'template': template_pk, 'items': [item.pk]},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
# A PDF output should have been produced despite the blocked resource.
|
||||
self.assertTrue(response.data['output'].endswith('.pdf'))
|
||||
|
||||
# The fetcher must have logged a warning for the blocked SSRF attempt.
|
||||
blocked_warnings = [
|
||||
msg
|
||||
for msg in captured.output
|
||||
if 'blocked URL' in msg and '127.0.0.1' in msg
|
||||
]
|
||||
self.assertTrue(
|
||||
blocked_warnings, 'Expected a blocked SSRF URL warning in the log output'
|
||||
)
|
||||
|
||||
def test_fetch_urls_disabled_blocks_http(self):
|
||||
"""When REPORT_FETCH_URLS is False, any http/https URL in a template must be blocked."""
|
||||
from io import StringIO
|
||||
|
||||
from common.settings import set_global_setting
|
||||
|
||||
# Use a publicly routable address so the test would reach the network if
|
||||
# our guard were absent — we want to confirm it is stopped by the setting,
|
||||
# not by a secondary SSRF IP check.
|
||||
html = (
|
||||
'<html><body>'
|
||||
'<img src="https://example.com/image.png">'
|
||||
'<p>Security test content</p>'
|
||||
'</body></html>'
|
||||
)
|
||||
template_io = StringIO(html)
|
||||
template_io.name = 'fetch_disabled_test_template.html'
|
||||
|
||||
response = self.post(
|
||||
reverse('api-report-template-list'),
|
||||
data={
|
||||
'name': 'Fetch Disabled Test',
|
||||
'description': 'Tests that HTTP fetching is blocked when REPORT_FETCH_URLS=False',
|
||||
'template': template_io,
|
||||
'model_type': 'stockitem',
|
||||
},
|
||||
format=None,
|
||||
expected_code=201,
|
||||
)
|
||||
template_pk = response.data['pk']
|
||||
|
||||
item = StockItem.objects.first()
|
||||
self.assertIsNotNone(item)
|
||||
|
||||
set_global_setting('REPORT_FETCH_URLS', False, change_user=None)
|
||||
|
||||
with self.assertLogs('inventree', level='WARNING') as captured:
|
||||
response = self.post(
|
||||
reverse('api-report-print'),
|
||||
{'template': template_pk, 'items': [item.pk]},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
self.assertTrue(response.data['output'].endswith('.pdf'))
|
||||
|
||||
blocked_warnings = [
|
||||
msg
|
||||
for msg in captured.output
|
||||
if 'REPORT_FETCH_URLS' in msg and 'example.com' in msg
|
||||
]
|
||||
self.assertTrue(
|
||||
blocked_warnings, 'Expected a REPORT_FETCH_URLS warning in the log output'
|
||||
)
|
||||
|
||||
|
||||
class URLFetcherTest(TestCase):
|
||||
"""Tests for InvenTreeURLFetcher security restrictions."""
|
||||
|
||||
def setUp(self):
|
||||
"""Import fetcher for each test."""
|
||||
from report.fetcher import InvenTreeURLFetcher
|
||||
|
||||
self.fetcher = InvenTreeURLFetcher()
|
||||
|
||||
def test_file_url_blocked(self):
|
||||
"""file:// URLs must always be rejected regardless of path."""
|
||||
for url in [
|
||||
'file:///etc/passwd',
|
||||
'file:///proc/self/environ',
|
||||
f'file://{settings.MEDIA_ROOT}/report/assets/anything.png',
|
||||
f'file://{settings.STATIC_ROOT}/some/font.ttf',
|
||||
]:
|
||||
with self.assertRaises(ValueError, msg=f'Expected block for {url}'):
|
||||
self.fetcher.fetch(url)
|
||||
|
||||
def test_unknown_scheme_blocked(self):
|
||||
"""Non-http/data/file schemes must be rejected."""
|
||||
for url in ['ftp://example.com/file.txt', 'javascript://x']:
|
||||
with self.assertRaises(ValueError, msg=f'Expected block for {url}'):
|
||||
self.fetcher.fetch(url)
|
||||
|
||||
def test_data_uri_allowed(self):
|
||||
"""data: URIs must always be permitted."""
|
||||
with patch('weasyprint.urls.URLFetcher.fetch', return_value={}):
|
||||
self.fetcher.fetch('data:image/png;base64,abc123')
|
||||
self.fetcher.fetch('data:text/css;base64,abc123')
|
||||
|
||||
@@ -199,6 +199,7 @@ export default function SystemSettings() {
|
||||
'REPORT_ENABLE',
|
||||
'REPORT_DEFAULT_PAGE_SIZE',
|
||||
'REPORT_DEBUG_MODE',
|
||||
'REPORT_FETCH_URLS',
|
||||
'REPORT_LOG_ERRORS',
|
||||
'LABEL_ENABLE',
|
||||
'LABEL_DPI'
|
||||
|
||||
Reference in New Issue
Block a user