diff --git a/backend/app/services/report/render.py b/backend/app/services/report/render.py index 624a500..c397cd5 100644 --- a/backend/app/services/report/render.py +++ b/backend/app/services/report/render.py @@ -12,6 +12,7 @@ from datetime import datetime import jinja2 +import jinja2.sandbox from app.services.report.markdown import prepare_docx_context from app.services.report.report_data import ReportContext @@ -63,7 +64,10 @@ def render_html_report( Image data is already embedded as base64 in the context (FileReport.data_base64), so templates handle image rendering client-side. """ - env = jinja2.Environment(autoescape=True) + # Use a SandboxedEnvironment so that templates (which originate from an + # external, admin-seeded source via CUSTOM_DATA_URL) cannot execute + # arbitrary Python via Jinja2 SSTI user generates a report. + env = jinja2.sandbox.SandboxedEnvironment(autoescape=True) env.filters["tojson"] = _tojson_filter template = env.from_string(template_content.decode("utf-8")) @@ -90,7 +94,10 @@ def render_docx_report( tpl = DocxTemplate(io.BytesIO(template_content)) docx_context = prepare_docx_context(asdict(context), tpl, image_data) - tpl.render(docx_context) + # Render with a SandboxedEnvironment to prevent Jinja2 SSTI/RCE from + # externally-sourced (admin-seeded) templates. docxtpl manages XML escaping + # itself, so autoescape stays off (the default). + tpl.render(docx_context, jinja_env=jinja2.sandbox.SandboxedEnvironment()) output = io.BytesIO() tpl.save(output) diff --git a/backend/app/services/report/report.py b/backend/app/services/report/report.py index 09505b5..4811e95 100644 --- a/backend/app/services/report/report.py +++ b/backend/app/services/report/report.py @@ -7,9 +7,11 @@ from datetime import datetime from fastapi import HTTPException, status +from jinja2.exceptions import SecurityError, TemplateError from sqlalchemy import select from sqlalchemy.orm import Session +from app.core.logging import app_logger from app.enums.enums import ReportTemplateFormat from app.models.report_template import ReportTemplate from app.models.user import User @@ -54,33 +56,57 @@ def generate_report_service( sort_order=request.sort_order, ) - if template.format == ReportTemplateFormat.HTML: - output = render_html_report(template.template_content, context) - result = GeneratedReport( - content=output.encode("utf-8"), - media_type="text/html", - filename=template.filename, + try: + if template.format == ReportTemplateFormat.HTML: + output = render_html_report(template.template_content, context) + result = GeneratedReport( + content=output.encode("utf-8"), + media_type="text/html", + filename=template.filename, + ) + del context, output + release_memory() + return result + elif template.format == ReportTemplateFormat.DOCX: + # Collect image data for DOCX embedding (markdown → InlineImage) + image_data = collect_report_images(session, assessment_id) + output = render_docx_report(template.template_content, context, image_data) + + del context, image_data + release_memory() + + return GeneratedReport( + content=output, + media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + filename=template.filename, + ) + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported format: {template.format}", + ) + except SecurityError as e: + # The template tried to access a sandbox-disallowed attribute — a + # blocked SSTI attempt. Log as a security event (template id/filename + # and the non-sensitive sandbox message) but keep the client response + # generic so no payload/traceback is leaked. + app_logger.error( + f"Rejected report template id={template.id} " + f"filename={template.filename!r}: blocked unsafe expression: {e}" ) - del context, output - release_memory() - return result - elif template.format == ReportTemplateFormat.DOCX: - # Collect image data for DOCX embedding (markdown → InlineImage) - image_data = collect_report_images(session, assessment_id) - output = render_docx_report(template.template_content, context, image_data) - - del context, image_data - release_memory() - - return GeneratedReport( - content=output, - media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", - filename=template.filename, + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="The selected report template was rejected: it contains disallowed expressions.", + ) + except TemplateError as e: + # Malformed template (syntax / undefined / runtime error). + app_logger.error( + f"Failed to render report template id={template.id} " + f"filename={template.filename!r}: {e}" ) - else: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Unsupported format: {template.format}", + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="The selected report template could not be rendered. Please check the template.", ) diff --git a/backend/tests/services/report/test_render.py b/backend/tests/services/report/test_render.py new file mode 100644 index 0000000..5f9bc8c --- /dev/null +++ b/backend/tests/services/report/test_render.py @@ -0,0 +1,77 @@ +""" +Unit tests for the report render layer. + +Focus: the Jinja2 SandboxedEnvironment must block SSTI/RCE payloads on both the +HTML and DOCX paths while still rendering benign templates correctly. +""" + +import io +from datetime import datetime + +import pytest +from docx import Document +from jinja2.exceptions import SecurityError + +from app.services.report.render import render_docx_report, render_html_report +from app.services.report.report_data import AssessmentInfo, ReportContext + +# Classic Jinja2 SSTI -> RCE payload (harmless `id` command). +RCE_PAYLOAD = ( + "{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}" +) + + +def _context() -> ReportContext: + """Minimal, DB-free ReportContext for rendering.""" + return ReportContext( + assessment=AssessmentInfo( + id="1", name="Acme Engagement", description="", assessment_type="RedTeam" + ), + activities_grouped=[], + activities_flat=[], + statistics={}, + generated_at=datetime(2026, 1, 1), + generated_by="tester", + template_filename="t", + ) + + +def _docx_bytes(paragraph_text: str) -> bytes: + doc = Document() + doc.add_paragraph(paragraph_text) + buf = io.BytesIO() + doc.save(buf) + return buf.getvalue() + + +def test_html_sandbox_blocks_rce(): + template = f"
{RCE_PAYLOAD}".encode()
+ with pytest.raises(SecurityError):
+ render_html_report(template, _context())
+
+
+def test_docx_sandbox_blocks_rce():
+ template = _docx_bytes(RCE_PAYLOAD)
+ with pytest.raises(SecurityError):
+ render_docx_report(template, _context(), None)
+
+
+def test_html_benign_renders_and_tojson_works():
+ template = (
+ b"