From 6114d99e655053ac6530ac7a56252f625d691917 Mon Sep 17 00:00:00 2001
From: Christopher Maddalena
Date: Thu, 30 Jul 2026 22:03:46 -0700
Subject: [PATCH 1/6] Harden Jinja report rendering
---
CHANGELOG.md | 6 +-
ghostwriter/api/tests/test_views.py | 81 +++-
ghostwriter/api/views.py | 27 +-
ghostwriter/modules/reportwriter/__init__.py | 148 +++++-
ghostwriter/modules/reportwriter/base/base.py | 27 +-
ghostwriter/modules/reportwriter/base/docx.py | 136 ++++--
.../reportwriter/base/html_rich_text.py | 173 +++++--
.../modules/reportwriter/project/base.py | 3 +-
.../tests/test_rich_text_templating.py | 432 +++++++++++++++++-
ghostwriter/reporting/tests/test_views.py | 177 +++++--
ghostwriter/reporting/views2/report.py | 20 +-
.../rich_text_editor/oplog_outline.tsx | 96 +++-
javascript/src/tiptap_gw/index.ts | 3 +
javascript/src/tiptap_gw/jinja_literal.ts | 68 +++
14 files changed, 1210 insertions(+), 187 deletions(-)
create mode 100644 javascript/src/tiptap_gw/jinja_literal.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 475d19d7a..5686bf43d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
-## [7.2.5] - 27 July 2026
+## [7.2.5] - 31 July 2026
### Changed
@@ -22,6 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Security
+* Hardened Jinja2 report rendering against sandbox escapes while preserving user-authored report templates and previews
+ * Operation-log values are treated as literal report data, including values containing captured Jinja2 payloads
+ * Lazy rich-text rendering now rejects templates that were not compiled by Ghostwriter's sandboxed environment
+ * Report template objects, Python callables, and document-export objects no longer expose unsafe attributes or call paths to Jinja2
* Hardened user-controlled values rendered in JavaScript contexts to prevent stored cross-site scripting
* Autocomplete data is now serialized as inert JSON instead of being interpolated into JavaScript source
* Tag autocomplete suggestions are scoped to objects the current user can access
diff --git a/ghostwriter/api/tests/test_views.py b/ghostwriter/api/tests/test_views.py
index 79f37bf86..a4af78941 100644
--- a/ghostwriter/api/tests/test_views.py
+++ b/ghostwriter/api/tests/test_views.py
@@ -1,5 +1,6 @@
# Standard Libraries
import base64
+import copy
import json
import logging
import os
@@ -64,6 +65,7 @@
StaticServerFactory,
UserFactory,
)
+from ghostwriter.modules.reportwriter import jinja_string_literal, prepare_jinja2_env
from ghostwriter.oplog.utils import (
CAST_GZIP_TOO_LARGE_UPLOAD_MESSAGE,
get_cast_decompressed_bytes,
@@ -3566,15 +3568,90 @@ def test_graphql_evidence_update_event(self):
)
self.assertEqual(response.status_code, 200)
self.finding.refresh_from_db()
+ encoded_name = jinja_string_literal("New Name")
+ inline_evidence = "{{ mk_evidence(" + encoded_name + ") }}"
+ evidence_reference = "{{ mk_ref(" + encoded_name + ") }}"
self.assertEqual(
self.finding.description,
- "Here is some evidence:
{{.New Name}}
{{.ref New Name}}
",
+ f"Here is some evidence:
{inline_evidence}
"
+ f"{evidence_reference}
",
)
self.assertEqual(
self.finding.impact,
- "Here is some evidence:
{{.New Name}}
{{.ref New Name}}
",
+ f"Here is some evidence:
{inline_evidence}
"
+ f"{evidence_reference}
",
)
+ def test_graphql_evidence_update_encodes_friendly_name_as_literal_data(self):
+ payload = "safe}}CLIENT={{ client.name }}{{.ref safe"
+ update_data = copy.deepcopy(self.update_data_report)
+ update_data["event"]["data"]["new"]["friendly_name"] = payload
+
+ response = self.client.post(
+ self.uri,
+ content_type="application/json",
+ data=update_data,
+ **{
+ "HTTP_HASURA_ACTION_SECRET": f"{ACTION_SECRET}",
+ },
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.finding.refresh_from_db()
+ self.assertNotIn(payload, self.finding.description)
+
+ captured_names = []
+
+ def capture_name(name):
+ captured_names.append(name)
+ return ""
+
+ env = prepare_jinja2_env()
+ env.globals["mk_evidence"] = capture_name
+ env.globals["mk_ref"] = capture_name
+ rendered = env.from_string(self.finding.description).render(
+ client={"name": "Victim Client"},
+ )
+
+ self.assertEqual(captured_names, [payload, payload])
+ self.assertNotIn("Victim Client", rendered)
+
+ def test_graphql_evidence_update_rewrites_an_encoded_previous_name(self):
+ first_response = self.client.post(
+ self.uri,
+ content_type="application/json",
+ data=self.update_data_report,
+ **{
+ "HTTP_HASURA_ACTION_SECRET": f"{ACTION_SECRET}",
+ },
+ )
+ self.assertEqual(first_response.status_code, 200)
+
+ second_update = copy.deepcopy(self.update_data_report)
+ second_update["event"]["data"]["old"]["friendly_name"] = "New Name"
+ second_update["event"]["data"]["new"]["friendly_name"] = "Final Name"
+ second_response = self.client.post(
+ self.uri,
+ content_type="application/json",
+ data=second_update,
+ **{
+ "HTTP_HASURA_ACTION_SECRET": f"{ACTION_SECRET}",
+ },
+ )
+
+ self.assertEqual(second_response.status_code, 200)
+ self.finding.refresh_from_db()
+ final_literal = jinja_string_literal("Final Name")
+ self.assertIn(
+ "{{ mk_evidence(" + final_literal + ") }}",
+ self.finding.description,
+ )
+ self.assertIn(
+ "{{ mk_ref(" + final_literal + ") }}",
+ self.finding.description,
+ )
+ self.assertNotIn(jinja_string_literal("New Name"), self.finding.description)
+
def test_graphql_evidence_delete_event(self):
self.assertTrue(os.path.exists(self.deleted_evidence.document.path))
response = self.client.post(
diff --git a/ghostwriter/api/views.py b/ghostwriter/api/views.py
index ed2b9efdb..769164cce 100644
--- a/ghostwriter/api/views.py
+++ b/ghostwriter/api/views.py
@@ -57,6 +57,7 @@
from ghostwriter.modules import codenames
from ghostwriter.modules.model_utils import set_finding_positions, to_dict
from ghostwriter.modules.passive_voice.detector import get_detector
+from ghostwriter.modules.reportwriter import jinja_string_literal
from ghostwriter.modules.reportwriter.report.json import ExportReportJson
from ghostwriter.oplog.models import OplogEntry, OplogEntryEvidence, OplogEntryRecording
from ghostwriter.oplog.utils import extract_cast_text, validate_cast_gzip_upload
@@ -1985,12 +1986,22 @@ def post(self, request, *args, **kwargs):
friendly = None
friendly_ref = None
if self.event["op"] == "UPDATE":
- friendly = f"{{{{.{self.new_data['friendly_name']}}}}}"
- friendly_ref = f"{{{{.ref {self.new_data['friendly_name']}}}}}"
+ encoded_name = jinja_string_literal(self.new_data["friendly_name"])
+ friendly = f"{{{{ mk_evidence({encoded_name}) }}}}"
+ friendly_ref = f"{{{{ mk_ref({encoded_name}) }}}}"
# Track previous friendly name and reference
prev_friendly = f"{{{{.{self.old_data['friendly_name']}}}}}"
prev_friendly_ref = f"{{{{.ref {self.old_data['friendly_name']}}}}}"
+ encoded_previous_name = jinja_string_literal(
+ self.old_data["friendly_name"]
+ )
+ prev_encoded_friendly = (
+ f"{{{{ mk_evidence({encoded_previous_name}) }}}}"
+ )
+ prev_encoded_friendly_ref = (
+ f"{{{{ mk_ref({encoded_previous_name}) }}}}"
+ )
logger.info(
"Updating content of ReportFindingLink instances with updated name for Evidence %s",
@@ -2011,9 +2022,21 @@ def post(self, request, *args, **kwargs):
if self.event["op"] == "DELETE":
new = current.replace(f"{prev_friendly}
", "")
new = new.replace(prev_friendly_ref, "")
+ new = new.replace(
+ f"{prev_encoded_friendly}
", ""
+ )
+ new = new.replace(prev_encoded_friendly_ref, "")
else:
new = current.replace(prev_friendly, friendly)
new = new.replace(prev_friendly_ref, friendly_ref)
+ new = new.replace(
+ prev_encoded_friendly,
+ friendly,
+ )
+ new = new.replace(
+ prev_encoded_friendly_ref,
+ friendly_ref,
+ )
setattr(instance, field.name, new)
instance.save()
except ReportFindingLink.DoesNotExist:
diff --git a/ghostwriter/modules/reportwriter/__init__.py b/ghostwriter/modules/reportwriter/__init__.py
index 7c4d95f42..b545a06a5 100644
--- a/ghostwriter/modules/reportwriter/__init__.py
+++ b/ghostwriter/modules/reportwriter/__init__.py
@@ -5,9 +5,14 @@
# Standard Libraries
import logging
+import types
# 3rd Party Libraries
import jinja2
+import jinja2.environment
+import jinja2.ext
+import jinja2.nodes
+import jinja2.runtime
import jinja2.sandbox
from ghostwriter.modules.reportwriter import jinja_funcs
@@ -15,6 +20,115 @@
logger = logging.getLogger(__name__)
+def jinja_string_literal(value: str) -> str:
+ """
+ Encode a value as Jinja string-literal source without emitting delimiters.
+
+ Every code point is escaped so HTML parsing and sanitizer normalization cannot
+ turn data into Jinja syntax before the template is compiled.
+ """
+ return '"' + "".join(f"\\U{ord(character):08x}" for character in value) + '"'
+
+
+class ReportSandboxedEnvironment(jinja2.sandbox.ImmutableSandboxedEnvironment):
+ """
+ Immutable Jinja environment for all report-controlled template source.
+
+ Compiler, template, and extension objects are capabilities rather than report
+ data. Templates must never inspect or call through them, even if a future
+ context accidentally exposes one.
+ """
+
+ _blocked_attribute_types = (
+ jinja2.environment.Environment,
+ jinja2.environment.Template,
+ jinja2.environment.TemplateStream,
+ jinja2.ext.Extension,
+ jinja2.nodes.Node,
+ jinja2.runtime.Context,
+ types.ModuleType,
+ )
+
+ _blocked_callable_attribute_types = (
+ types.FunctionType,
+ types.BuiltinFunctionType,
+ types.MethodType,
+ types.BuiltinMethodType,
+ )
+
+ _blocked_object_module_prefixes = (
+ "docx.",
+ "docxtpl.",
+ )
+
+ @classmethod
+ def _is_blocked_class(cls, obj):
+ """Return whether obj is a class for one of the blocked capability types."""
+ return isinstance(obj, type) and issubclass(obj, cls._blocked_attribute_types)
+
+ @classmethod
+ def _is_blocked_object(cls, obj):
+ """Return whether an object must expose no template capabilities."""
+ if obj is None:
+ return False
+ return (
+ getattr(type(obj), "_jinja_block_all_attributes", False)
+ or isinstance(obj, cls._blocked_attribute_types)
+ or cls._is_blocked_class(obj)
+ or type(obj).__module__.startswith(cls._blocked_object_module_prefixes)
+ )
+
+ def _is_registered_callable(self, obj):
+ """Return whether a Python function or class was explicitly registered."""
+ return any(
+ obj is candidate
+ for registry in (self.globals, self.filters, self.tests)
+ for candidate in registry.values()
+ ) or any(
+ obj is candidate
+ for candidate in (
+ jinja_funcs.caption,
+ jinja_funcs.ref,
+ jinja_funcs.mk_evidence,
+ )
+ )
+
+ def is_safe_attribute(self, obj, attr, value):
+ """Deny access to application-marked objects and Jinja internals."""
+ if self._is_blocked_object(obj) or isinstance(
+ obj,
+ self._blocked_callable_attribute_types,
+ ):
+ return False
+ return super().is_safe_attribute(obj, attr, value)
+
+ def is_safe_callable(self, obj):
+ """Deny calls on Jinja capability objects as defense in depth."""
+ owner = (
+ obj.__self__
+ if isinstance(
+ obj,
+ (
+ types.MethodType,
+ types.BuiltinMethodType,
+ ),
+ )
+ else None
+ )
+ if self._is_blocked_object(obj) or self._is_blocked_object(owner):
+ return False
+ if isinstance(
+ obj,
+ (
+ types.FunctionType,
+ types.BuiltinFunctionType,
+ type,
+ ),
+ ):
+ return self._is_registered_callable(obj)
+ return super().is_safe_callable(obj)
+
+
def prepare_jinja2_env(debug=False):
"""Prepare a Jinja2 environment with all custom filters."""
if debug:
@@ -46,7 +160,7 @@ def __bool__(self):
else:
undefined = jinja2.make_logging_undefined(logger=logger, base=jinja2.Undefined)
- env = jinja2.sandbox.SandboxedEnvironment(undefined=undefined, extensions=["jinja2.ext.debug"], autoescape=True)
+ env = ReportSandboxedEnvironment(undefined=undefined, autoescape=True)
env.filters["filter_severity"] = jinja_funcs.filter_severity
env.filters["filter_type"] = jinja_funcs.filter_type
env.filters["strip_html"] = jinja_funcs.strip_html
@@ -59,24 +173,34 @@ def __bool__(self):
env.filters["regex_search"] = jinja_funcs.regex_search
env.filters["filter_tags"] = jinja_funcs.filter_tags
env.filters["replace_blanks"] = jinja_funcs.replace_blanks
- env.filters["filter_bhe_findings_by_domain"] = jinja_funcs.filter_bhe_findings_by_domain
+ env.filters[
+ "filter_bhe_findings_by_domain"
+ ] = jinja_funcs.filter_bhe_findings_by_domain
env.filters["translate_domain_sid"] = jinja_funcs.translate_domain_sid
if debug:
return env, undefined_vars
return env
+
def report_generation_queryset():
"""
Gets a queryset of Reports with `select_related` and `prefetch_related` options optimal for report generation.
"""
- from ghostwriter.reporting.models import Report # pylint: disable=import-outside-toplevel
- return Report.objects.all().prefetch_related(
- "tags",
- "reportfindinglink_set",
- "reportobservationlink_set",
- "evidence_set",
- "project__oplog_set",
- "project__oplog_set__entries",
- "project__oplog_set__entries__tags",
- ).select_related()
+ from ghostwriter.reporting.models import (
+ Report,
+ ) # pylint: disable=import-outside-toplevel
+
+ return (
+ Report.objects.all()
+ .prefetch_related(
+ "tags",
+ "reportfindinglink_set",
+ "reportobservationlink_set",
+ "evidence_set",
+ "project__oplog_set",
+ "project__oplog_set__entries",
+ "project__oplog_set__entries__tags",
+ )
+ .select_related()
+ )
diff --git a/ghostwriter/modules/reportwriter/base/base.py b/ghostwriter/modules/reportwriter/base/base.py
index 5b1d65820..f5b1e3b88 100644
--- a/ghostwriter/modules/reportwriter/base/base.py
+++ b/ghostwriter/modules/reportwriter/base/base.py
@@ -12,7 +12,11 @@
from ghostwriter.commandcenter.models import CompanyInformation, ExtraFieldSpec
from ghostwriter.modules.reportwriter import prepare_jinja2_env
from ghostwriter.modules.reportwriter.base import ReportExportTemplateError
-from ghostwriter.modules.reportwriter.base.html_rich_text import LazilyRenderedTemplate, rich_text_template
+from ghostwriter.modules.reportwriter.base.html_rich_text import (
+ HtmlRichText,
+ LazilyRenderedTemplate,
+ rich_text_template,
+)
class ExportBase:
@@ -132,6 +136,27 @@ def process_extra_fields(self, location: str, extra_fields: dict, model, context
context,
)
+ def process_literal_extra_fields(
+ self,
+ location: str,
+ extra_fields: dict,
+ model,
+ ):
+ """
+ Fill extra-field defaults without treating rich-text values as templates.
+
+ This is used for data sources such as operation logs, whose values may
+ contain Jinja payloads recorded during an assessment.
+ """
+ for field in self.extra_field_specs_for(model):
+ if field.internal_name not in extra_fields:
+ extra_fields[field.internal_name] = field.empty_value()
+ if field.type == "rich_text":
+ extra_fields[field.internal_name] = HtmlRichText(
+ str(extra_fields[field.internal_name]),
+ f"extra field {field.internal_name} of {location}",
+ )
+
def map_rich_texts(self):
"""
Replaces rich text entries in `self.data` with `LazilyRenderedTemplate` or `HtmlAndRich` instances.
diff --git a/ghostwriter/modules/reportwriter/base/docx.py b/ghostwriter/modules/reportwriter/base/docx.py
index bf4be35c7..8c73127ad 100644
--- a/ghostwriter/modules/reportwriter/base/docx.py
+++ b/ghostwriter/modules/reportwriter/base/docx.py
@@ -34,12 +34,13 @@
"Caption",
"List Paragraph",
"Blockquote",
- "footnote text", # Lowercase to match style name
- "footnote reference" # Lowercase to match style name
+ "footnote text", # Lowercase to match style name
+ "footnote reference", # Lowercase to match style name
] + [f"Heading {i}" for i in range(1, 7)]
_img_desc_replace_re = re.compile(r"^\s*\[\s*([a-zA-Z0-9_]+)\s*\]\s*(.*)$")
+
class ExportDocxBase(ExportBase):
"""
Base class for exporting DOCX (Word) documents.
@@ -92,10 +93,18 @@ def __init__(
try:
self.word_doc = DocxTemplate(report_template.document.path)
except PackageNotFoundError as err:
- logger.exception("Failed to load the provided template document: %s", report_template.document.path)
- raise ReportExportTemplateError("Template document file could not be found - try re-uploading it") from err
+ logger.exception(
+ "Failed to load the provided template document: %s",
+ report_template.document.path,
+ )
+ raise ReportExportTemplateError(
+ "Template document file could not be found - try re-uploading it"
+ ) from err
except Exception:
- logger.exception("Failed to load the provided template document: %s", report_template.document.path)
+ logger.exception(
+ "Failed to load the provided template document: %s",
+ report_template.document.path,
+ )
raise
self.global_report_config = ReportConfiguration.get_solo()
@@ -113,21 +122,28 @@ def run(self) -> io.BytesIO:
)
ReportExportTemplateError.map_errors(
- lambda: self.word_doc.render(docx_context, self.jinja_env, autoescape=True), "the DOCX template"
+ lambda: self.word_doc.render(
+ docx_context, self.jinja_env, autoescape=True
+ ),
+ "the DOCX template",
)
ReportExportTemplateError.map_errors(
lambda: self.render_properties(docx_context), "the DOCX properties"
)
except UnrecognizedImageError as err:
- raise ReportExportTemplateError(f"Could not load an image: {err}", "the DOCX template") from err
+ raise ReportExportTemplateError(
+ f"Could not load an image: {err}", "the DOCX template"
+ ) from err
except PackageNotFoundError as err:
raise ReportExportTemplateError(
- "The word template could not be found on the server – try uploading it again.", "the DOCX template"
+ "The word template could not be found on the server – try uploading it again.",
+ "the DOCX template",
) from err
except FileNotFoundError as err:
logger.exception("Missing file")
raise ReportExportTemplateError(
- "An evidence file was missing – try uploading it again.", "the DOCX template"
+ "An evidence file was missing – try uploading it again.",
+ "the DOCX template",
) from err
out = io.BytesIO()
@@ -155,7 +171,10 @@ def _cleanup_footnote_separators(self, docx_bytes: io.BytesIO) -> io.BytesIO:
# Access the footnotes part (requires accessing internal python-docx members)
# pylint: disable=protected-access
- if not hasattr(doc._part, '_footnotes_part') or doc._part._footnotes_part is None:
+ if (
+ not hasattr(doc._part, "_footnotes_part")
+ or doc._part._footnotes_part is None
+ ):
docx_bytes.seek(0)
return docx_bytes
@@ -275,16 +294,19 @@ def render_properties(self, context: dict):
continue
out = ReportExportTemplateError.map_errors(
- lambda: self.jinja_env.from_string(template_src).render(context), f"DOCX property {attr}"
+ lambda: self.jinja_env.from_string(template_src).render(context),
+ f"DOCX property {attr}",
)
setattr(self.word_doc.core_properties, attr, out)
- def render_rich_text_docx(self, rich_text: RichTextBase) -> LazySubdocRender | DocxRichText:
+ def render_rich_text_docx(
+ self, rich_text: RichTextBase
+ ) -> LazySubdocRender | DocxRichText:
"""
Renders a `RichTextBase`, converting the HTML from the rich text editor, to a Word subdoc.
"""
if isinstance(rich_text, HtmlAndObject):
- return rich_text.obj
+ return rich_text.exporter_object
def render():
doc = self.word_doc.new_subdoc()
@@ -300,6 +322,7 @@ def render():
getattr(rich_text, "location", None),
)
return doc
+
return LazySubdocRender(render)
def replace_images(self):
@@ -324,14 +347,16 @@ def replace_images(self):
for hp in headers_and_footers:
if not hp._has_definition:
continue
- toplevels.append((
- hp.part,
- hp.part._element,
- ))
+ toplevels.append(
+ (
+ hp.part,
+ hp.part._element,
+ )
+ )
# Go through each part and replace matcing drawings
image_rids_and_objs = {}
- for (part, element) in toplevels:
+ for part, element in toplevels:
for drawing in element.xpath(".//w:drawing"):
docpr = next(iter(drawing.xpath(".//wp:docPr")), None)
if docpr is None:
@@ -340,7 +365,10 @@ def replace_images(self):
blip = next(iter(drawing.xpath(".//pic:pic//a:blip")), None)
if blip is None:
continue
- if "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed" not in blip.attrib:
+ if (
+ "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed"
+ not in blip.attrib
+ ):
continue
# Get image name from alt text
@@ -364,8 +392,9 @@ def replace_images(self):
image_rids_and_objs[key] = rid
# Replace image
- blip.attrib["{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed"] = rid
-
+ blip.attrib[
+ "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed"
+ ] = rid
@classmethod
def lint(cls, report_template: ReportTemplate) -> Tuple[List[str], List[str]]:
@@ -381,7 +410,10 @@ def lint(cls, report_template: ReportTemplate) -> Tuple[List[str], List[str]]:
logger.info("Linting docx file %r", report_template.document.path)
try:
if not os.path.exists(report_template.document.path):
- logger.error("Template file path did not exist: %r", report_template.document.path)
+ logger.error(
+ "Template file path did not exist: %r",
+ report_template.document.path,
+ )
errors.append("Template file does not exist – upload it again")
return warnings, errors
@@ -395,42 +427,72 @@ def lint(cls, report_template: ReportTemplate) -> Tuple[List[str], List[str]]:
logger.info("Template loaded for linting")
undeclared_variables = ReportExportTemplateError.map_errors(
- lambda: exporter.word_doc.get_undeclared_template_variables(exporter.jinja_env), "the DOCX template"
+ lambda: exporter.word_doc.get_undeclared_template_variables(
+ exporter.jinja_env
+ ),
+ "the DOCX template",
)
for variable in undeclared_variables:
if variable not in lint_data:
- warnings.append("Potential undefined variable: {!r}".format(variable))
+ warnings.append(
+ "Potential undefined variable: {!r}".format(variable)
+ )
document_styles = exporter.word_doc.get_docx().styles
for style in EXPECTED_STYLES:
if style not in document_styles:
- warnings.append("Template is missing a recommended style (see documentation): " + style)
+ warnings.append(
+ "Template is missing a recommended style (see documentation): "
+ + style
+ )
else:
if style == "CodeInline":
if document_styles[style].type != WD_STYLE_TYPE.CHARACTER:
- warnings.append("CodeInline style is not a character style (see documentation)")
+ warnings.append(
+ "CodeInline style is not a character style (see documentation)"
+ )
if style == "CodeBlock":
if document_styles[style].type != WD_STYLE_TYPE.PARAGRAPH:
- warnings.append("CodeBlock style is not a paragraph style (see documentation)")
+ warnings.append(
+ "CodeBlock style is not a paragraph style (see documentation)"
+ )
if style == "Bullet List":
if document_styles[style].type != WD_STYLE_TYPE.PARAGRAPH:
- warnings.append("Bullet List style is not a paragraph style (see documentation)")
+ warnings.append(
+ "Bullet List style is not a paragraph style (see documentation)"
+ )
if style == "Number List":
if document_styles[style].type != WD_STYLE_TYPE.PARAGRAPH:
- warnings.append("Number List style is not a paragraph style (see documentation)")
+ warnings.append(
+ "Number List style is not a paragraph style (see documentation)"
+ )
if style == "List Paragraph":
if document_styles[style].type != WD_STYLE_TYPE.PARAGRAPH:
- warnings.append("List Paragraph style is not a paragraph style (see documentation)")
+ warnings.append(
+ "List Paragraph style is not a paragraph style (see documentation)"
+ )
if style == "footnote text":
if document_styles[style].type != WD_STYLE_TYPE.PARAGRAPH:
- warnings.append("Footnote Text style is not a character style (see documentation)")
+ warnings.append(
+ "Footnote Text style is not a character style (see documentation)"
+ )
if style == "footnote reference":
if document_styles[style].type != WD_STYLE_TYPE.CHARACTER:
- warnings.append("Footnote Reference style is not a character style (see documentation)")
+ warnings.append(
+ "Footnote Reference style is not a character style (see documentation)"
+ )
if "Table Grid" not in document_styles:
- errors.append("Template is missing a required style (see documentation): Table Grid")
- if report_template.p_style and report_template.p_style not in document_styles:
- warnings.append("Template is missing your configured default paragraph style: " + report_template.p_style)
+ errors.append(
+ "Template is missing a required style (see documentation): Table Grid"
+ )
+ if (
+ report_template.p_style
+ and report_template.p_style not in document_styles
+ ):
+ warnings.append(
+ "Template is missing your configured default paragraph style: "
+ + report_template.p_style
+ )
exporter.run()
@@ -443,7 +505,9 @@ def lint(cls, report_template: ReportTemplate) -> Tuple[List[str], List[str]]:
logger.exception("Template failed linting")
errors.append("Template rendering failed unexpectedly")
- logger.info("Linting finished: %d warnings, %d errors", len(warnings), len(errors))
+ logger.info(
+ "Linting finished: %d warnings, %d errors", len(warnings), len(errors)
+ )
return warnings, errors
def bloodhound_heading_offset(self) -> int:
diff --git a/ghostwriter/modules/reportwriter/base/html_rich_text.py b/ghostwriter/modules/reportwriter/base/html_rich_text.py
index 73ba4dd08..12279c496 100644
--- a/ghostwriter/modules/reportwriter/base/html_rich_text.py
+++ b/ghostwriter/modules/reportwriter/base/html_rich_text.py
@@ -1,15 +1,74 @@
-
+# Standard Libraries
import re
+import secrets
+from abc import ABC, abstractmethod
from typing import Any, Callable
+
+# 3rd Party Libraries
import bs4
import jinja2
from markupsafe import Markup
-from abc import ABC, abstractmethod
-from ghostwriter.modules.reportwriter import jinja_funcs
+# Ghostwriter Libraries
+from ghostwriter.modules.reportwriter import ReportSandboxedEnvironment, jinja_funcs
from ghostwriter.modules.reportwriter.base import ReportExportTemplateError
_H = [f"h{n}" for n in range(1, 7)]
+JINJA_LITERAL_ATTRIBUTE = "data-gw-jinja-literal"
+
+
+def _require_report_sandbox(template_or_environment):
+ """Reject templates that were not created by Ghostwriter's report sandbox."""
+ environment = (
+ template_or_environment.environment
+ if isinstance(template_or_environment, jinja2.Template)
+ else template_or_environment
+ )
+ if not isinstance(environment, ReportSandboxedEnvironment):
+ raise TypeError("Rich-text templates must use ReportSandboxedEnvironment")
+
+
+class CompiledRichTextTemplate:
+ """
+ A compiled rich-text template with HTML fragments excluded from Jinja parsing.
+
+ Literal fragments are represented by unpredictable inert placeholders while
+ Jinja compiles and renders the rest of the rich text. They are restored only
+ after the single template-rendering pass has completed.
+ """
+
+ _jinja_block_all_attributes = True
+
+ def __init__(self, template: jinja2.Template, literal_fragments: dict[str, str]):
+ _require_report_sandbox(template)
+ self._template = template
+ self._literal_fragments = literal_fragments
+
+ def render(self, *args, **kwargs):
+ rendered = self._template.render(*args, **kwargs)
+ for placeholder, literal_html in self._literal_fragments.items():
+ rendered = rendered.replace(placeholder, literal_html)
+ return rendered
+
+
+def _extract_jinja_literal_fragments(text: str) -> tuple[str, dict[str, str]]:
+ """
+ Replace marked HTML containers with inert placeholders before Jinja parsing.
+
+ The marker container itself is intentionally omitted from the rendered output;
+ only its contents are restored. Nested markers are handled by their outermost
+ marked ancestor.
+ """
+ soup = bs4.BeautifulSoup(text, "html.parser")
+ literal_fragments = {}
+ for node in soup.find_all(attrs={JINJA_LITERAL_ATTRIBUTE: True}):
+ if node.find_parent(attrs={JINJA_LITERAL_ATTRIBUTE: True}) is not None:
+ continue
+
+ placeholder = f"GWJINJALITERAL{secrets.token_hex(24)}"
+ literal_fragments[placeholder] = node.decode_contents()
+ node.replace_with(placeholder)
+ return str(soup), literal_fragments
def remove_trailing_empty_paragraphs(body):
@@ -32,11 +91,17 @@ def remove_trailing_empty_paragraphs(body):
def rich_text_template(
env: jinja2.Environment,
text: str,
-) -> jinja2.Template:
+) -> CompiledRichTextTemplate:
"""
Converts rich text `text` to a Jinja template. This does some additional Ghostwriter-specific
processing.
"""
+ _require_report_sandbox(env)
+
+ # Remove generated literal data from the source before any normalization can
+ # assemble or interpret Jinja delimiters.
+ text, literal_fragments = _extract_jinja_literal_fragments(text)
+
# Replace old `{{.item}}`` syntax with jinja templates or elements to replace
def replace_old_tag(match: re.Match):
contents = match.group(1).strip()
@@ -55,9 +120,7 @@ def replace_old_tag(match: re.Match):
text = re.sub(r"\{\{\s*\.([^\{\}]*?)\s*\}\}", replace_old_tag, text)
# Replace TinyMCE page breaks with something that the parser can easily pick up
- text = text.replace(
- "
", ' '
- )
+ text = text.replace("
", ' ')
# Replace `{%li foreach %}`-esque prefixes. This is similar to what python-docx-template does.
soup = bs4.BeautifulSoup(text, "html.parser")
@@ -69,7 +132,10 @@ def replace_old_tag(match: re.Match):
# Compile
try:
- return env.from_string(text)
+ return CompiledRichTextTemplate(
+ env.from_string(text),
+ literal_fragments,
+ )
except jinja2.TemplateSyntaxError as err:
line = text.splitlines()[err.lineno - 1]
raise ReportExportTemplateError(str(err), code_context=line) from err
@@ -81,7 +147,9 @@ def _process_prefix(input_str: str, soup: bs4.BeautifulSoup, prefix: str):
in the passed-in soup.
"""
- regex = re.compile(r"^\s*(\{%|\{\{)\s*" + re.escape(prefix) + r"\b(.*)(%\}|\}\})\s*$")
+ regex = re.compile(
+ r"^\s*(\{%|\{\{)\s*" + re.escape(prefix) + r"\b(.*)(%\}|\}\})\s*$"
+ )
# Store in list since we mutate the nodes
matching_strings = list(soup.find_all(string=regex))
for node in matching_strings:
@@ -93,7 +161,10 @@ def _process_prefix(input_str: str, soup: bs4.BeautifulSoup, prefix: str):
break
if parent_tag is None:
line = input_str.splitlines()[node.parent.sourceline - 1]
- raise ReportExportTemplateError(f"Jinja tag prefixed with '{prefix}' was not a descendant of a {prefix} tag", code_context=line)
+ raise ReportExportTemplateError(
+ f"Jinja tag prefixed with '{prefix}' was not a descendant of a {prefix} tag",
+ code_context=line,
+ )
capture = regex.search(node)
parent_tag.replace_with(capture.group(1) + capture.group(2) + capture.group(3))
@@ -104,6 +175,10 @@ class RichTextBase(ABC):
Base class for a value that can produce some rich text, represented as HTML.
"""
+ # Templates only need the rendered value. Public attributes and methods are
+ # implementation details and must not expose application or Jinja objects.
+ _jinja_block_all_attributes = True
+
# User-friendly descriptor of where the rich text was produced
location: str | None
@@ -114,25 +189,32 @@ def __html__(self) -> Markup | str:
"""
@staticmethod
- def deep_copy_process_html(value: Any, process_html: Callable[["RichTextBase"], Any]):
+ def deep_copy_process_html(
+ value: Any, process_html: Callable[["RichTextBase"], Any]
+ ):
"""
Deep copies a value, mapping any `RichTextBase` subclasses through `process_html`.
"""
if isinstance(value, RichTextBase):
return process_html(value)
if isinstance(value, dict):
- return {k: RichTextBase.deep_copy_process_html(v, process_html) for k,v in value.items()}
+ return {
+ k: RichTextBase.deep_copy_process_html(v, process_html)
+ for k, v in value.items()
+ }
if isinstance(value, list):
return [RichTextBase.deep_copy_process_html(v, process_html) for v in value]
return value
+
class HtmlRichText(RichTextBase):
"""
An HTML string, with no templating.
"""
+
html: str
- def __init__(self, html: str, location: str | None=None):
+ def __init__(self, html: str, location: str | None = None):
super().__init__()
self.html = html
self.location = location
@@ -140,45 +222,57 @@ def __init__(self, html: str, location: str | None=None):
def __html__(self):
return self.html
+
class LazilyRenderedTemplate(RichTextBase):
"""
Renders a Jinja template lazily
"""
- template: jinja2.Template
- context: dict
+
location: str | None
- rendered: str | None
- rendering: bool
- def __init__(self, template: jinja2.Template, location: str | None, context: dict):
+ def __init__(
+ self,
+ template: CompiledRichTextTemplate,
+ location: str | None,
+ context: dict,
+ ):
super().__init__()
- self.template = template
- self.context = context
+ if not isinstance(template, CompiledRichTextTemplate):
+ raise TypeError(
+ "LazilyRenderedTemplate requires a CompiledRichTextTemplate"
+ )
+ self._template = template
+ self._context = context
self.location = location
- self.rendered = None
- self.rendering = False
+ self._rendered = None
+ self._rendering = False
def render_html(self):
"""
Will throw a `ReportExportTemplateError` if the template attempted to render itself while it was
rendering (i.e. infinite recursion).
"""
- if self.rendered is None:
- if self.rendering:
- raise ReportExportTemplateError(f"Circular reference to {self.location} (ensure rich text fields are not referencing each other)")
- self.rendering = True
- self.rendered = Markup(
- ReportExportTemplateError.map_errors(
- lambda: self.template.render(self.context),
- self.location,
+ if self._rendered is None:
+ if self._rendering:
+ raise ReportExportTemplateError(
+ f"Circular reference to {self.location} (ensure rich text fields are not referencing each other)"
)
- )
- self.rendering = False
- return self.rendered
+ self._rendering = True
+ try:
+ self._rendered = Markup(
+ ReportExportTemplateError.map_errors(
+ lambda: self._template.render(self._context),
+ self.location,
+ )
+ )
+ finally:
+ self._rendering = False
+ return self._rendered
def __html__(self):
return self.render_html()
+
class HtmlAndObject(RichTextBase):
"""
HTML rich text and an exporter-specific object (ex. a docx `RichText`).
@@ -187,21 +281,29 @@ class HtmlAndObject(RichTextBase):
"""
html: str
- obj: Any
def __init__(self, html: str, obj, location: str | None = None):
super().__init__()
self.html = html
- self.obj = obj
+ self._obj = obj
self.location = location
+ @property
+ def exporter_object(self):
+ """Return the exporter-specific object to trusted exporter code."""
+ return self._obj
+
def __html__(self):
return self.html
+
class LazySubdocRender:
"""
Renders a subdocument via a render function lazily
"""
+
+ _jinja_block_all_attributes = True
+
def __init__(self, render):
self._render = render
self._rendered = None
@@ -216,6 +318,7 @@ def __html__(self):
self._rendered = self._render()
return self._rendered.__html__()
+
def offset_headings(html: str, heading_offset: int):
"""
Increases the level of `h1-6` tags in the `html`.
diff --git a/ghostwriter/modules/reportwriter/project/base.py b/ghostwriter/modules/reportwriter/project/base.py
index 649d589d9..e332adeea 100644
--- a/ghostwriter/modules/reportwriter/project/base.py
+++ b/ghostwriter/modules/reportwriter/project/base.py
@@ -192,11 +192,10 @@ def process_projects_richtext(
# Logs
for log in base_context["logs"]:
for entry in log["entries"]:
- ex.process_extra_fields(
+ ex.process_literal_extra_fields(
f"log entry {entry['description']} of log {log['name']}",
entry["extra_fields"],
OplogEntry,
- rich_text_context,
)
# BloodHound findings
diff --git a/ghostwriter/reporting/tests/test_rich_text_templating.py b/ghostwriter/reporting/tests/test_rich_text_templating.py
index 1e8d8e8eb..5437f4374 100644
--- a/ghostwriter/reporting/tests/test_rich_text_templating.py
+++ b/ghostwriter/reporting/tests/test_rich_text_templating.py
@@ -1,40 +1,392 @@
+# Standard Libraries
+from pathlib import Path
+from tempfile import TemporaryDirectory
+# Django Imports
from django.test import SimpleTestCase, TestCase
-from ghostwriter.factories import ExtraFieldModelFactory, ExtraFieldSpecFactory, ReportFactory
-from ghostwriter.modules.reportwriter import prepare_jinja2_env
+# 3rd Party Libraries
+import jinja2
+from docxtpl import RichText as DocxRichText
+from jinja2.exceptions import SecurityError
+
+# Ghostwriter Libraries
+from ghostwriter.factories import (
+ ExtraFieldModelFactory,
+ ExtraFieldSpecFactory,
+ OplogEntryFactory,
+ ReportFactory,
+)
+from ghostwriter.modules.reportwriter import jinja_funcs, prepare_jinja2_env
from ghostwriter.modules.reportwriter.base import ReportExportTemplateError
from ghostwriter.modules.reportwriter.base.base import ExportBase
-from ghostwriter.modules.reportwriter.base.html_rich_text import rich_text_template
+from ghostwriter.modules.reportwriter.base.html_rich_text import (
+ CompiledRichTextTemplate,
+ HtmlRichText,
+ LazilyRenderedTemplate,
+ rich_text_template,
+)
+from ghostwriter.modules.reportwriter.forms import JinjaRichTextField
from ghostwriter.modules.reportwriter.report.docx import ExportReportDocx
+from ghostwriter.modules.reportwriter.report.json import ExportReportJson
+from ghostwriter.oplog.models import OplogEntry
from ghostwriter.reporting.models import Report
class RichTextTemplatingTests(SimpleTestCase):
maxDiff = None
+ @staticmethod
+ def render_with_project_description(source):
+ """Render source with the rich-text object used by the reported escapes."""
+ env = prepare_jinja2_env()
+ context = {}
+ context["project"] = {
+ "description_rt": LazilyRenderedTemplate(
+ rich_text_template(env, ""),
+ "the project description",
+ context,
+ )
+ }
+ return LazilyRenderedTemplate(
+ rich_text_template(env, source),
+ "test rich text",
+ context,
+ ).render_html()
+
+ def test_debug_extension_is_not_enabled(self):
+ env, undefined_variables = prepare_jinja2_env(debug=True)
+
+ self.assertNotIn("jinja2.ext.DebugExtension", env.extensions)
+ with self.assertRaises(jinja2.TemplateSyntaxError):
+ env.from_string("{% debug %}")
+
+ env.from_string("{{ missing_variable }}").render()
+ self.assertEqual(undefined_variables, {"missing_variable"})
+
+ def test_sandbox_does_not_expose_rich_text_attributes(self):
+ env = prepare_jinja2_env()
+ rich_text = LazilyRenderedTemplate(
+ rich_text_template(env, "Safe content
"),
+ "sensitive location",
+ {},
+ )
+
+ rendered = env.from_string(
+ "{{ rich_text }}|{{ rich_text.location }}|" "{{ rich_text.render_html }}"
+ ).render(rich_text=rich_text)
+
+ self.assertEqual(rendered, "Safe content
||")
+
+ def test_lazy_template_rejects_raw_jinja_template(self):
+ template = jinja2.Template("{{ cycler.__init__.__globals__.os.name }}")
+
+ with self.assertRaisesMessage(
+ TypeError,
+ "LazilyRenderedTemplate requires a CompiledRichTextTemplate",
+ ):
+ LazilyRenderedTemplate(template, "test", {})
+
+ def test_rich_text_template_rejects_unsandboxed_environment(self):
+ env = jinja2.Environment()
+
+ with self.assertRaisesMessage(
+ TypeError,
+ "Rich-text templates must use ReportSandboxedEnvironment",
+ ):
+ rich_text_template(env, "safe")
+
+ with self.assertRaisesMessage(
+ TypeError,
+ "Rich-text templates must use ReportSandboxedEnvironment",
+ ):
+ CompiledRichTextTemplate(env.from_string("safe"), {})
+
+ def test_sandbox_is_immutable(self):
+ env = prepare_jinja2_env()
+
+ with self.assertRaises(SecurityError):
+ env.from_string("{{ values.clear() }}").render(values=["safe"])
+
+ def test_documented_jinja_features_remain_available(self):
+ env = prepare_jinja2_env()
+ template = env.from_string(
+ "{% set totals=namespace(value=0) %}"
+ '{% for item in items|sort(attribute="name") %}'
+ "{{ loop.index }}:{{ item.name|upper }};"
+ "{% set totals.value=totals.value + item.value %}"
+ "{% endfor %}"
+ 'match={{ "Ghostwriter 42"|regex_search("[0-9]+") }};'
+ "total={{ totals.value }}"
+ )
+
+ rendered = template.render(
+ items=[
+ {"name": "beta", "value": 2},
+ {"name": "alpha", "value": 3},
+ ]
+ )
+
+ self.assertEqual(rendered, "1:ALPHA;2:BETA;match=42;total=5")
+
+ def test_standard_jinja_globals_remain_available(self):
+ env = prepare_jinja2_env()
+
+ self.assertTrue(
+ {"cycler", "dict", "joiner", "lipsum", "namespace", "range"}
+ <= env.globals.keys()
+ )
+
+ def test_registered_globals_and_template_macros_remain_callable(self):
+ env = prepare_jinja2_env()
+ template = env.from_string(
+ "{% macro render_value(value) %}[{{ value }}]{% endmacro %}"
+ "{% for number in range(3) %}"
+ "{{ render_value(dict(value=number).value) }}"
+ "{% endfor %}"
+ '{% set rows=cycler("odd", "even") %}'
+ "{{ rows.next() }}-{{ rows.next() }}|"
+ '{% set comma=joiner(",") %}'
+ "{{ comma() }}a{{ comma() }}b"
+ )
+
+ self.assertEqual(template.render(), "[0][1][2]odd-even|a,b")
+
+ def test_unregistered_python_callable_is_blocked(self):
+ env = prepare_jinja2_env()
+
+ def unregistered(value):
+ return value
+
+ with self.assertRaises(SecurityError):
+ env.from_string("{{ unregistered('unsafe') }}").render(
+ unregistered=unregistered
+ )
+
+ rendered = env.from_string('{{ mk_ref("Example") }}').render(
+ mk_ref=jinja_funcs.ref
+ )
+ self.assertIn('data-gw-ref="Example"', rendered)
+
+ def test_python_callable_attributes_are_blocked(self):
+ env = prepare_jinja2_env()
+
+ rendered = env.from_string(
+ "{{ mk_ref.__name__ }}|{{ mk_ref.jinja_pass_arg }}"
+ ).render(mk_ref=jinja_funcs.ref)
+
+ self.assertEqual(rendered, "|")
+
+ def test_docx_exporter_objects_expose_no_template_attributes(self):
+ env = prepare_jinja2_env()
+ rich_text = DocxRichText("Safe")
+
+ rendered = env.from_string("{{ value.xml }}|{{ value.add }}").render(
+ value=rich_text
+ )
+
+ self.assertEqual(rendered, "|")
+
+ def test_common_generic_sandbox_escape_families_are_blocked(self):
+ env = prepare_jinja2_env()
+ payloads = [
+ '{{ cycler.__init__.__globals__["os"] }}',
+ '{{ lipsum.__globals__["os"] }}',
+ '{{ namespace.__init__.__globals__["os"] }}',
+ "{{ dict.__base__ }}",
+ '{{ "".__class__.__mro__ }}',
+ '{{ (namespace|attr("__init__"))|attr("__globals__") }}',
+ '{{ "{0.__class__}".format("x") }}',
+ '{{ "{x.__class__}".format_map({"x": "y"}) }}',
+ ]
+
+ for payload in payloads:
+ with self.subTest(payload=payload):
+ try:
+ rendered = env.from_string(payload).render()
+ except SecurityError:
+ continue
+ self.assertEqual(rendered, "")
+
+ def test_rich_text_form_accepts_user_authored_jinja(self):
+ source = (
+ '{% for value in ["one", "two"] %}'
+ "{{ loop.index }}={{ value }} "
+ "{% endfor %}
"
+ )
+
+ self.assertEqual(JinjaRichTextField().clean(source), source)
+
+ def test_nested_rich_text_still_renders_as_template_data(self):
+ env = prepare_jinja2_env()
+ context = {"client": {"name": "Example Client"}}
+ context["project"] = {
+ "description_rt": LazilyRenderedTemplate(
+ rich_text_template(
+ env,
+ "{{ client.name }} ",
+ ),
+ "the project description",
+ context,
+ )
+ }
+ outer = LazilyRenderedTemplate(
+ rich_text_template(
+ env,
+ "{{ project.description_rt }} ",
+ ),
+ "outer rich text",
+ context,
+ )
+
+ self.assertEqual(
+ outer.render_html(),
+ "",
+ )
+
+ def test_marked_oplog_content_is_restored_only_after_jinja_rendering(self):
+ env = prepare_jinja2_env()
+ payload = (
+ "{% set e=project.description_rt.template.environment %}"
+ "{{ e.template_class("
+ "\"{{ cycler.__init__.__globals__.os.popen('id').read() }}\""
+ ").render() }}"
+ )
+ source = (
+ "Authored template: {{ client.name }}
"
+ ''
+ f"
{payload}
"
+ "
{{ client.name }} {% endraw %} {#"
+ "
"
+ "Still authored: {{ client.name|upper }}
"
+ )
+
+ rendered = rich_text_template(env, source).render(
+ client={"name": "Example Client"},
+ project={},
+ )
+
+ self.assertIn("Authored template: Example Client", rendered)
+ self.assertIn("Still authored: EXAMPLE CLIENT", rendered)
+ self.assertIn(payload, rendered)
+ self.assertIn("{{ client.name }} {% endraw %} {#", rendered)
+ self.assertNotIn("data-gw-jinja-literal", rendered)
+
+ def test_marked_oplog_content_survives_html_entity_normalization(self):
+ env = prepare_jinja2_env()
+ source = (
+ ''
+ "
{{ client.name }}
"
+ '
'
+ "{{.ref safe}}CLIENT={{ client.name }}{{.ref safe}}"
+ "
"
+ "
"
+ )
+
+ rendered = rich_text_template(env, source).render(
+ client={"name": "Rendered Client"}
+ )
+
+ self.assertIn("{{ client.name }}", rendered)
+ self.assertIn("safe}}CLIENT={{ client.name }}{{.ref safe", rendered)
+ self.assertNotIn("Rendered Client", rendered)
+
+ def test_debug_extension_ast_escape_is_blocked(self):
+ payload = """
+ {% set e=project.description_rt.template.environment %}
+ {% set a=e.parse('{{ 0 }}') %}
+ {% set x=a.body[0].nodes.clear() %}
+ {% set x=a.body[0].nodes.append(e.extensions['jinja2.ext.DebugExtension'].call_method('__init__.__globals__["__builtins__"]["__import__"]("os").popen("id").read')) %}
+ {{ e.from_string(a).render() }}
+ """
+
+ with self.assertRaises(ReportExportTemplateError):
+ self.render_with_project_description(payload)
+
+ def test_unsandboxed_template_constructor_escape_is_blocked(self):
+ payload = """
+ {{ project.description_rt.template.environment.template_class(
+ "{{ cycler.__init__.__globals__.os.popen('id').read() }}"
+ ).render() }}
+ """
+
+ with self.assertRaises(ReportExportTemplateError):
+ self.render_with_project_description(payload)
+
+ def test_environment_capabilities_are_blocked_if_directly_exposed(self):
+ env = prepare_jinja2_env()
+ payload = '{{ exposed.template_class("{{ 7 * 7 }}").render() }}'
+
+ with self.assertRaises(SecurityError):
+ env.from_string(payload).render(exposed=env)
+
+ direct_template_payload = '{{ exposed("{{ 7 * 7 }}").render() }}'
+ with self.assertRaises(SecurityError):
+ env.from_string(direct_template_payload).render(
+ exposed=jinja2.environment.Template
+ )
+
+ extension = jinja2.ext.DebugExtension(env)
+ with self.assertRaises(SecurityError):
+ env.from_string('{{ exposed.call_method("anything") }}').render(
+ exposed=extension
+ )
+
+ parsed_ast = env.parse("{{ 0 }}")
+ with self.assertRaises(SecurityError):
+ env.from_string('{{ exposed.set_ctx("load") }}').render(exposed=parsed_ast)
+
+ def test_template_stream_file_write_is_blocked_if_template_is_exposed(self):
+ env = prepare_jinja2_env()
+ exposed_template = env.from_string("attacker-controlled")
+
+ with TemporaryDirectory() as temp_dir:
+ destination = Path(temp_dir) / "written.py"
+ payload = "{{ exposed.stream().dump(destination) }}"
+
+ with self.assertRaises(SecurityError):
+ env.from_string(payload).render(
+ exposed=exposed_template,
+ destination=str(destination),
+ )
+
+ self.assertFalse(destination.exists())
+
def test_list(self):
env, _ = prepare_jinja2_env(debug=True)
- template = rich_text_template(env, "{%li for i in thelist %} {{i}} {%li endfor %} ")
- out = template.render({
- "thelist": ["foo", "bar", "baz"]
- })
+ template = rich_text_template(
+ env,
+ "{%li for i in thelist %} {{i}} {%li endfor %} ",
+ )
+ out = template.render({"thelist": ["foo", "bar", "baz"]})
self.assertEqual(out, "foo bar baz ")
def test_table(self):
env, _ = prepare_jinja2_env(debug=True)
- template = rich_text_template(env, "{%tr for row in thelist%} {{row[0]}} {{row[1]}} {%tr endfor %}
")
- out = template.render({
- "thelist": [["foo", 1], ["bar", 2], ["baz", 3]]
- })
- self.assertEqual(out, "")
+ template = rich_text_template(
+ env,
+ "{%tr for row in thelist%} {{row[0]}} {{row[1]}} {%tr endfor %}
",
+ )
+ out = template.render({"thelist": [["foo", 1], ["bar", 2], ["baz", 3]]})
+ self.assertEqual(
+ out,
+ "",
+ )
def test_prefix_not_nested(self):
env, _ = prepare_jinja2_env(debug=True)
- with self.assertRaisesMessage(ReportExportTemplateError, "Jinja tag prefixed with 'li' was not a descendant of a li tag"):
- rich_text_template(env, "{%li for i in thelist %}{{i}} {%li endfor %} ")
+ with self.assertRaisesMessage(
+ ReportExportTemplateError,
+ "Jinja tag prefixed with 'li' was not a descendant of a li tag",
+ ):
+ rich_text_template(
+ env,
+ "{%li for i in thelist %}{{i}} {%li endfor %} ",
+ )
- def test_legacy_reference_and_caption_tags_accept_whitespace_after_opening_braces(self):
+ def test_legacy_reference_and_caption_tags_accept_whitespace_after_opening_braces(
+ self,
+ ):
env, _ = prepare_jinja2_env(debug=True)
template = rich_text_template(
env,
@@ -45,7 +397,9 @@ def test_legacy_reference_and_caption_tags_accept_whitespace_after_opening_brace
'{{ .caption Here is a Caption}}
',
)
out = template.render({})
- self.assertIn('data-gw-ref="Payload Hosting and Lateral Movement With Codex"', out)
+ self.assertIn(
+ 'data-gw-ref="Payload Hosting and Lateral Movement With Codex"', out
+ )
self.assertIn('data-gw-caption="Here is a Caption"', out)
@@ -70,11 +424,15 @@ def mime_type(cls) -> str:
def extension(cls) -> str:
return "txt"
- lazy_template = DummyExport({}, is_raw=True).create_lazy_template("test rich text", None, {})
+ lazy_template = DummyExport({}, is_raw=True).create_lazy_template(
+ "test rich text", None, {}
+ )
self.assertEqual(lazy_template.render_html(), "")
- def test_report_export_handles_report_extra_field_with_spaced_legacy_reference_and_caption_tags(self):
+ def test_report_export_handles_report_extra_field_with_spaced_legacy_reference_and_caption_tags(
+ self,
+ ):
report_extra_field = ExtraFieldModelFactory(
model_internal_name=Report._meta.label,
model_display_name="Reports",
@@ -106,3 +464,41 @@ def test_report_export_handles_report_extra_field_with_spaced_legacy_reference_a
out = ExportReportDocx(report, report_template=report.docx_template).run()
self.assertGreater(len(out.getvalue()), 0)
+
+ def test_oplog_rich_text_extra_field_is_literal_report_data(self):
+ oplog_extra_field = ExtraFieldModelFactory(
+ model_internal_name=OplogEntry._meta.label,
+ model_display_name="Oplog Entries",
+ )
+ ExtraFieldSpecFactory(
+ internal_name="analyst_notes",
+ display_name="Analyst Notes",
+ type="rich_text",
+ target_model=oplog_extra_field,
+ )
+ report = ReportFactory()
+ payload = (
+ "{{ client.name }}
"
+ "{% set e=project.description_rt.template.environment %}
"
+ )
+ OplogEntryFactory(
+ oplog_id__project=report.project,
+ oplog_id__name="Literal log",
+ extra_fields={"analyst_notes": payload},
+ )
+
+ exporter = ExportReportJson(report)
+ context = exporter.map_rich_texts()
+ log = next(log for log in context["logs"] if log["name"] == "Literal log")
+ literal_value = log["entries"][0]["extra_fields"]["analyst_notes"]
+
+ self.assertIsInstance(literal_value, HtmlRichText)
+ self.assertEqual(literal_value.__html__(), payload)
+
+ rendered = exporter.create_lazy_template(
+ "test oplog reference",
+ "{{ logs[0].entries[0].extra_fields.analyst_notes }}",
+ {**context, "logs": [log]},
+ ).render_html()
+ self.assertEqual(str(rendered), payload)
+ self.assertNotIn(report.project.client.name, rendered)
diff --git a/ghostwriter/reporting/tests/test_views.py b/ghostwriter/reporting/tests/test_views.py
index 2415c0385..b57409aee 100644
--- a/ghostwriter/reporting/tests/test_views.py
+++ b/ghostwriter/reporting/tests/test_views.py
@@ -1033,14 +1033,22 @@ def test_tags_are_scoped_to_findings(self):
self.assertIn("visible-finding-tag", tag_names)
self.assertNotIn("hidden-report-tag", tag_names)
self.assertNotIn("hidden-project-tag", tag_names)
- self.assertIn("visible-finding-tag", response.context["autocomplete_data"]["tags"])
- self.assertNotIn("hidden-report-tag", response.context["autocomplete_data"]["tags"])
- self.assertNotIn("hidden-project-tag", response.context["autocomplete_data"]["tags"])
+ self.assertIn(
+ "visible-finding-tag", response.context["autocomplete_data"]["tags"]
+ )
+ self.assertNotIn(
+ "hidden-report-tag", response.context["autocomplete_data"]["tags"]
+ )
+ self.assertNotIn(
+ "hidden-project-tag", response.context["autocomplete_data"]["tags"]
+ )
def test_search_report_findings(self):
response = self.client_auth.get(self.uri + "?on_reports=on")
self.assertEqual(response.status_code, 200)
- self.assertEqual(len(response.context["filter"].qs), len(self.accessibleReportFindings))
+ self.assertEqual(
+ len(response.context["filter"].qs), len(self.accessibleReportFindings)
+ )
response = self.client_auth.get(self.uri + "?on_reports=on¬_cloned=on")
self.assertEqual(response.status_code, 200)
@@ -1435,8 +1443,12 @@ def test_tags_are_scoped_to_visible_reports(self):
tag_names = list(response.context["tags"].values_list("name", flat=True))
self.assertIn("visible-report-tag", tag_names)
self.assertNotIn("hidden-report-tag", tag_names)
- self.assertIn("visible-report-tag", response.context["autocomplete_data"]["tags"])
- self.assertNotIn("hidden-report-tag", response.context["autocomplete_data"]["tags"])
+ self.assertIn(
+ "visible-report-tag", response.context["autocomplete_data"]["tags"]
+ )
+ self.assertNotIn(
+ "hidden-report-tag", response.context["autocomplete_data"]["tags"]
+ )
class ReportDetailViewTests(TestCase):
@@ -1492,9 +1504,7 @@ def test_report_caption_configuration_is_safe_in_javascript_html(self):
response = self.client_mgr.get(self.uri)
self.assertEqual(response.status_code, 200)
- self.assertContains(
- response, r"Figure\u2028window.captionXss\u003Dtrue//"
- )
+ self.assertContains(response, r"Figure\u2028window.captionXss\u003Dtrue//")
self.assertContains(response, r"\u0026lt\u003B/p\u0026gt\u003B")
self.assertNotContains(response, "
Initial foothold confirmed.",
},
{"type": "paragraph", "text": "Output:"},
- {"type": "code", "text": "{% raw %}PORT 80/tcp open http{% endraw %}"},
- {"type": "paragraph", "text": "{{.ref Alpha}}"},
+ {"type": "code", "text": "PORT 80/tcp open http"},
+ {"type": "reference", "ref": "Alpha"},
{"type": "evidence", "evidence_id": report_evidence.id},
- {"type": "paragraph", "text": "{{.ref Bravo}}"},
+ {"type": "reference", "ref": "Bravo"},
{"type": "evidence", "evidence_id": finding_evidence.id},
{
"type": "narrative",
@@ -1747,9 +1760,7 @@ def test_view_generates_expected_outline_lines(self):
],
)
- def test_view_wraps_output_as_jinja_literal_text(self):
- from ghostwriter.modules.reportwriter import prepare_jinja2_env
-
+ def test_view_returns_output_as_unmodified_literal_block_data(self):
output = "\n".join(
[
"project={{ project }}",
@@ -1788,14 +1799,11 @@ def test_view_wraps_output_as_jinja_literal_text(self):
if block["type"] == "code"
]
self.assertEqual(len(code_blocks), 1)
+ self.assertEqual(code_blocks[0], output)
- rendered = prepare_jinja2_env(debug=False).from_string(code_blocks[0]).render(
- {"project": "Rendered Project"}
- )
- self.assertEqual(rendered, output)
- self.assertNotIn("project=Rendered Project", rendered)
-
- def test_view_includes_entries_matching_configured_exact_tag_case_insensitively(self):
+ def test_view_includes_entries_matching_configured_exact_tag_case_insensitively(
+ self,
+ ):
self.report_config.outline_tags = "Credential"
self.report_config.save()
@@ -2657,7 +2665,9 @@ def test_json_extra_field_modal_is_lazy_loaded(self):
self.assertNotIn("nested", rendered)
def test_report_detail_json_lazy_loader_has_loading_spinner(self):
- response = self.client_mgr.get(reverse("reporting:report_detail", kwargs={"pk": self.report.pk}))
+ response = self.client_mgr.get(
+ reverse("reporting:report_detail", kwargs={"pk": self.report.pk})
+ )
self.assertEqual(response.status_code, 200)
self.assertContains(response, "fa-spinner fa-spin")
self.assertContains(response, "Loading JSON content...")
@@ -2720,6 +2730,75 @@ def test_richtext_preview_endpoint_requires_login_and_permissions(self):
response = self.client_mgr.get(uri)
self.assertEqual(response.status_code, 200)
+ def test_richtext_preview_renders_user_authored_jinja(self):
+ self.report.extra_fields["narrative"] = (
+ "{{ client.name }}
"
+ '{% for value in ["one", "two"] %}'
+ "{{ loop.index }}={{ value }} "
+ "{% endfor %}
"
+ )
+ self.report.save(update_fields=["extra_fields"])
+ uri = reverse(
+ "reporting:report_extra_field_richtext",
+ kwargs={
+ "pk": self.report.pk,
+ "extra_field_name": self.extra_field.internal_name,
+ },
+ )
+
+ response = self.client_mgr.get(uri)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, self.report.project.client.name)
+ self.assertContains(response, "1=one 2=two")
+
+ def test_richtext_preview_blocks_jinja_environment_access(self):
+ self.report.extra_fields["narrative"] = (
+ "{{ project.description_rt.template.environment.template_class("
+ '"{{ 7 * 7 }}").render() }}
'
+ )
+ self.report.save(update_fields=["extra_fields"])
+ uri = reverse(
+ "reporting:report_extra_field_richtext",
+ kwargs={
+ "pk": self.report.pk,
+ "extra_field_name": self.extra_field.internal_name,
+ },
+ )
+
+ response = self.client_mgr.get(uri)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Template Error")
+ self.assertNotContains(response, ">49<")
+
+ def test_richtext_preview_treats_marked_oplog_content_as_literal_data(self):
+ self.report.extra_fields["narrative"] = (
+ ''
+ "
Logged payload: {{ client.name }}
"
+ "
{% endraw %}{#"
+ "
"
+ "Authored template: {{ client.name }}
"
+ )
+ self.report.save(update_fields=["extra_fields"])
+ uri = reverse(
+ "reporting:report_extra_field_richtext",
+ kwargs={
+ "pk": self.report.pk,
+ "extra_field_name": self.extra_field.internal_name,
+ },
+ )
+
+ response = self.client_mgr.get(uri)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Logged payload: {{ client.name }}")
+ self.assertContains(
+ response,
+ f"Authored template: {self.report.project.client.name}",
+ )
+ self.assertNotContains(response, "data-gw-jinja-literal")
+
def test_richtext_preview_endpoint_rejects_non_richtext_fields(self):
uri = reverse(
"reporting:report_extra_field_richtext",
@@ -2807,7 +2886,10 @@ class ExpandEvidenceAndSanitizeTests(TestCase):
"""Tests for expand_evidence_and_sanitize marker expansion."""
def test_ref_marker_expanded(self):
- from ghostwriter.commandcenter.templatetags.extra_fields import expand_evidence_and_sanitize
+ from ghostwriter.commandcenter.templatetags.extra_fields import (
+ expand_evidence_and_sanitize,
+ )
+
html = 'See for details
'
result = expand_evidence_and_sanitize(html, None)
self.assertIn("Figure", result)
@@ -2815,7 +2897,10 @@ def test_ref_marker_expanded(self):
self.assertNotIn("data-gw-ref", result)
def test_inline_caption_marker_expanded(self):
- from ghostwriter.commandcenter.templatetags.extra_fields import expand_evidence_and_sanitize
+ from ghostwriter.commandcenter.templatetags.extra_fields import (
+ expand_evidence_and_sanitize,
+ )
+
html = ' My Caption
'
result = expand_evidence_and_sanitize(html, None)
self.assertIn("Figure", result)
@@ -2823,7 +2908,10 @@ def test_inline_caption_marker_expanded(self):
self.assertNotIn("data-gw-caption", result)
def test_block_caption_wrapped_in_p(self):
- from ghostwriter.commandcenter.templatetags.extra_fields import expand_evidence_and_sanitize
+ from ghostwriter.commandcenter.templatetags.extra_fields import (
+ expand_evidence_and_sanitize,
+ )
+
html = 'Caption Text
'
result = expand_evidence_and_sanitize(html, None)
self.assertIn("", result)
@@ -2831,7 +2919,10 @@ def test_block_caption_wrapped_in_p(self):
self.assertIn("Figure", result)
def test_image_marker_without_client_decomposed(self):
- from ghostwriter.commandcenter.templatetags.extra_fields import expand_evidence_and_sanitize
+ from ghostwriter.commandcenter.templatetags.extra_fields import (
+ expand_evidence_and_sanitize,
+ )
+
html = '
'
result = expand_evidence_and_sanitize(html, None)
self.assertNotIn("CLIENT_LOGO", result)
@@ -2839,12 +2930,17 @@ def test_image_marker_without_client_decomposed(self):
def test_image_marker_with_client_logo(self):
from unittest.mock import MagicMock, PropertyMock, patch
- from ghostwriter.commandcenter.templatetags.extra_fields import expand_evidence_and_sanitize
+ from ghostwriter.commandcenter.templatetags.extra_fields import (
+ expand_evidence_and_sanitize,
+ )
+
client = ClientFactory()
logo_mock = MagicMock()
logo_mock.__bool__ = lambda s: True
logo_mock.name = "test_logo.png"
- with patch.object(type(client), "logo", new_callable=PropertyMock, return_value=logo_mock):
+ with patch.object(
+ type(client), "logo", new_callable=PropertyMock, return_value=logo_mock
+ ):
html = '
'
result = expand_evidence_and_sanitize(html, None, client=client)
self.assertIn(" {% for x in %}broken{% endfor %}"}
+ self.report.extra_fields = {
+ "test_rt": "{% for x in %}broken{% endfor %}
"
+ }
self.report.save(update_fields=["extra_fields"])
response = self.client_mgr.get(self.uri)
self.assertEqual(response.status_code, 200)
@@ -3217,7 +3321,9 @@ def test_template_error_returns_200_with_error_message(self):
self.assertIn("alert-danger", content)
def test_export_error_returns_generic_preview_error(self):
- self.report.extra_fields = {"test_rt": "{{ 'content'|regex_search('(') }}
"}
+ self.report.extra_fields = {
+ "test_rt": "{{ 'content'|regex_search('(') }}
"
+ }
self.report.save(update_fields=["extra_fields"])
response = self.client_mgr.get(self.uri)
@@ -5584,7 +5690,6 @@ def test_tags_are_scoped_to_filtered_observation_queryset(self):
class ObservationCreateViewTests(TestCase):
"""Collection of tests for :view:`reporting.ObservationCreate`."""
-
@classmethod
def setUpTestData(cls):
cls.observation = ObservationFactory()
diff --git a/ghostwriter/reporting/views2/report.py b/ghostwriter/reporting/views2/report.py
index 31c67e9e0..479c44ad0 100644
--- a/ghostwriter/reporting/views2/report.py
+++ b/ghostwriter/reporting/views2/report.py
@@ -50,7 +50,6 @@
logger = logging.getLogger(__name__)
channel_layer = get_channel_layer()
-JINJA_ENDRAW_RE = re.compile(r"{%[-+]?\s*endraw\s*[-+]?%}")
def _outline_value(value):
@@ -76,21 +75,6 @@ def _outline_command_value(command):
return command_text if command_text != "N/A" else ""
-def _outline_jinja_raw_text(value):
- """
- Return text wrapped so later rich-text Jinja rendering treats it as literal output.
- """
- text = value or ""
- return (
- "{% raw %}"
- + JINJA_ENDRAW_RE.sub(
- lambda match: "{% endraw %}{{ " + repr(match.group(0)) + " }}{% raw %}",
- text,
- )
- + "{% endraw %}"
- )
-
-
def _unavailable_template_response(request, report):
messages.error(
request,
@@ -204,10 +188,10 @@ def generate_oplog_outline_blocks(report: Report, oplog: Oplog) -> list[dict[str
if has_output:
blocks.append({"type": "paragraph", "text": "Output:"})
- blocks.append({"type": "code", "text": _outline_jinja_raw_text(output)})
+ blocks.append({"type": "code", "text": output})
for friendly_name, evidence_id in _report_evidence_refs_for_entry(report, entry):
- blocks.append({"type": "paragraph", "text": "{{.ref " + friendly_name + "}}"})
+ blocks.append({"type": "reference", "ref": friendly_name})
blocks.append({"type": "evidence", "evidence_id": evidence_id})
return blocks
diff --git a/javascript/src/frontend/collab_forms/rich_text_editor/oplog_outline.tsx b/javascript/src/frontend/collab_forms/rich_text_editor/oplog_outline.tsx
index 6e928f5e9..721876a2f 100644
--- a/javascript/src/frontend/collab_forms/rich_text_editor/oplog_outline.tsx
+++ b/javascript/src/frontend/collab_forms/rich_text_editor/oplog_outline.tsx
@@ -21,6 +21,7 @@ type OutlineBlock =
| { type: "paragraph"; text: string }
| { type: "html"; html: string }
| { type: "code"; text: string }
+ | { type: "reference"; ref: string }
| { type: "evidence"; evidence_id: number };
type ToastLevel = "success" | "warning" | "error" | "info";
@@ -49,10 +50,14 @@ function getOplogChoices(): OplogChoice[] {
}
function getGenerateUrl(): string {
- return document.getElementById("report-oplog-outline-url")?.textContent ?? "";
+ return (
+ document.getElementById("report-oplog-outline-url")?.textContent ?? ""
+ );
}
-function buildNarrativeContent(block: Extract) {
+function buildNarrativeContent(
+ block: Extract
+) {
const content: Array<{
type: "text";
text: string;
@@ -88,6 +93,20 @@ function buildNarrativeContent(block: Extract) {
+ return {
+ type: "jinjaLiteral",
+ content: [content],
+ };
+}
+
+function literalHtmlBlock(html: string) {
+ const wrapper = document.createElement("div");
+ wrapper.setAttribute("data-gw-jinja-literal", "true");
+ wrapper.innerHTML = html;
+ return wrapper.outerHTML;
+}
+
export default function OplogOutlineButton({ editor }: { editor: Editor }) {
const [isOpen, setIsOpen] = useState(false);
@@ -138,16 +157,19 @@ function OplogOutlineModal(props: { editor: Editor; onClose: () => void }) {
- Appends a narrative outline based on operation log entries tagged with
+ Appends a narrative outline based on operation log
+ entries tagged with
report
or
evidence
- to the end of this field. Any linked evidence will be included as well.
+ to the end of this field. Any linked evidence will be
+ included as well.
- This is useful for quickly assembling a report
- outline based on relevant activity recorded in the log(s).
- You can edit the generated outline as needed after it has been inserted.
+ This is useful for quickly assembling a report outline
+ based on relevant activity recorded in the log(s). You
+ can edit the generated outline as needed after it has
+ been inserted.
Operation Logs
@@ -273,34 +295,60 @@ async function appendOutline(
editor
.chain()
.focus("end")
- .insertContent({
- type: "paragraph",
- content: buildNarrativeContent(block),
- })
+ .insertContent(
+ literalBlock({
+ type: "paragraph",
+ content: buildNarrativeContent(block),
+ })
+ )
.run();
} else if (block.type === "paragraph") {
editor
.chain()
.focus("end")
- .insertContent({
- type: "paragraph",
- content: block.text
- ? [{ type: "text", text: block.text }]
- : [],
- })
+ .insertContent(
+ literalBlock({
+ type: "paragraph",
+ content: block.text
+ ? [{ type: "text", text: block.text }]
+ : [],
+ })
+ )
.run();
} else if (block.type === "html") {
- editor.chain().focus("end").insertContent(block.html).run();
+ editor
+ .chain()
+ .focus("end")
+ .insertContent(literalHtmlBlock(block.html))
+ .run();
} else if (block.type === "code") {
editor
.chain()
.focus("end")
- .insertContent({
- type: "codeBlock",
- content: block.text
- ? [{ type: "text", text: block.text }]
- : [],
- })
+ .insertContent(
+ literalBlock({
+ type: "codeBlock",
+ content: block.text
+ ? [{ type: "text", text: block.text }]
+ : [],
+ })
+ )
+ .run();
+ } else if (block.type === "reference") {
+ editor
+ .chain()
+ .focus("end")
+ .insertContent(
+ literalBlock({
+ type: "paragraph",
+ content: [
+ {
+ type: "jinjaReference",
+ attrs: { ref: block.ref },
+ },
+ ],
+ })
+ )
.run();
} else {
editor
diff --git a/javascript/src/tiptap_gw/index.ts b/javascript/src/tiptap_gw/index.ts
index 1320c9b65..188f4fa27 100644
--- a/javascript/src/tiptap_gw/index.ts
+++ b/javascript/src/tiptap_gw/index.ts
@@ -28,6 +28,7 @@ import Caption from "./caption";
import Footnote from "./footnote";
import { PassiveVoiceDecoration } from "./passive_voice_decoration";
import DateTimeShortcuts from "./now_shortcut";
+import JinjaLiteral, { JinjaReference } from "./jinja_literal";
const EXTENSIONS: Extensions = [
StarterKit.configure({
@@ -78,6 +79,8 @@ const EXTENSIONS: Extensions = [
Footnote,
PassiveVoiceDecoration,
DateTimeShortcuts,
+ JinjaLiteral,
+ JinjaReference,
];
export default EXTENSIONS;
diff --git a/javascript/src/tiptap_gw/jinja_literal.ts b/javascript/src/tiptap_gw/jinja_literal.ts
new file mode 100644
index 000000000..0248aa868
--- /dev/null
+++ b/javascript/src/tiptap_gw/jinja_literal.ts
@@ -0,0 +1,68 @@
+import { mergeAttributes, Node } from "@tiptap/core";
+
+/**
+ * Marks generated report content as literal data.
+ *
+ * The report renderer removes this wrapper before compiling the surrounding
+ * rich text as Jinja and restores its contents after rendering.
+ */
+const JinjaLiteral = Node.create({
+ name: "jinjaLiteral",
+ group: "block",
+ content: "block+",
+ defining: true,
+
+ parseHTML() {
+ return [{ tag: "div[data-gw-jinja-literal]" }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ "div",
+ mergeAttributes(HTMLAttributes, {
+ "data-gw-jinja-literal": "true",
+ }),
+ 0,
+ ];
+ },
+});
+
+/**
+ * A structured cross-reference used by generated oplog outlines.
+ *
+ * Keeping the reference name in a node attribute avoids constructing legacy
+ * `{{.ref ...}}` template source from an evidence friendly name.
+ */
+export const JinjaReference = Node.create({
+ name: "jinjaReference",
+ group: "inline",
+ inline: true,
+ atom: true,
+
+ addAttributes() {
+ return {
+ ref: {
+ default: "",
+ parseHTML: (element) =>
+ element.getAttribute("data-gw-ref") ?? "",
+ renderHTML: (attributes) => ({
+ "data-gw-ref": attributes.ref,
+ }),
+ },
+ };
+ },
+
+ parseHTML() {
+ return [{ tag: "span[data-gw-ref]" }];
+ },
+
+ renderText({ node }) {
+ return `{{.ref ${node.attrs.ref}}}`;
+ },
+
+ renderHTML({ node, HTMLAttributes }) {
+ return ["span", HTMLAttributes, `{{.ref ${node.attrs.ref}}}`];
+ },
+});
+
+export default JinjaLiteral;
From 7f4e9f05f312842e379eb8d9de74ba58ee88d137 Mon Sep 17 00:00:00 2001
From: Christopher Maddalena
Date: Thu, 30 Jul 2026 22:06:15 -0700
Subject: [PATCH 2/6] Updated build date
---
VERSION | 2 +-
config/settings/base.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/VERSION b/VERSION
index 59a108f1f..29c948dbc 100644
--- a/VERSION
+++ b/VERSION
@@ -1,2 +1,2 @@
v7.2.5
-27 July 2026
+31 July 2026
diff --git a/config/settings/base.py b/config/settings/base.py
index a5c4bdfd1..5d21c0026 100644
--- a/config/settings/base.py
+++ b/config/settings/base.py
@@ -13,7 +13,7 @@
__version__ = "7.2.5"
VERSION = __version__
-RELEASE_DATE = "27 July 2026"
+RELEASE_DATE = "31 July 2026"
ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent.parent
APPS_DIR = ROOT_DIR / "ghostwriter"
From 74042944dd87f9a24a05845a7560694818687465 Mon Sep 17 00:00:00 2001
From: Christopher Maddalena
Date: Thu, 30 Jul 2026 22:11:53 -0700
Subject: [PATCH 3/6] Improve Jinja globals assertion
---
ghostwriter/reporting/tests/test_rich_text_templating.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/ghostwriter/reporting/tests/test_rich_text_templating.py b/ghostwriter/reporting/tests/test_rich_text_templating.py
index 5437f4374..58625836c 100644
--- a/ghostwriter/reporting/tests/test_rich_text_templating.py
+++ b/ghostwriter/reporting/tests/test_rich_text_templating.py
@@ -132,9 +132,9 @@ def test_documented_jinja_features_remain_available(self):
def test_standard_jinja_globals_remain_available(self):
env = prepare_jinja2_env()
- self.assertTrue(
- {"cycler", "dict", "joiner", "lipsum", "namespace", "range"}
- <= env.globals.keys()
+ self.assertLessEqual(
+ {"cycler", "dict", "joiner", "lipsum", "namespace", "range"},
+ env.globals.keys(),
)
def test_registered_globals_and_template_macros_remain_callable(self):
From 56f384b5fd7ac1dab6fa90ae41d96953141f91b2 Mon Sep 17 00:00:00 2001
From: Christopher Maddalena
Date: Thu, 30 Jul 2026 22:43:25 -0700
Subject: [PATCH 4/6] Harden Jinja call and reference handling
---
ghostwriter/modules/reportwriter/__init__.py | 80 ++++++++++---
ghostwriter/modules/reportwriter/base/docx.py | 4 +-
.../reportwriter/base/html_rich_text.py | 51 ++++++++-
.../tests/test_rich_text_templating.py | 108 ++++++++++++++++++
ghostwriter/reporting/views2/report.py | 1 -
javascript/src/tiptap_gw/jinja_literal.ts | 59 ++++++++--
6 files changed, 274 insertions(+), 29 deletions(-)
diff --git a/ghostwriter/modules/reportwriter/__init__.py b/ghostwriter/modules/reportwriter/__init__.py
index b545a06a5..d1d968173 100644
--- a/ghostwriter/modules/reportwriter/__init__.py
+++ b/ghostwriter/modules/reportwriter/__init__.py
@@ -6,6 +6,7 @@
# Standard Libraries
import logging
import types
+from datetime import date, datetime, time, timedelta
# 3rd Party Libraries
import jinja2
@@ -22,7 +23,7 @@
def jinja_string_literal(value: str) -> str:
"""
- Encode a value as Jinja string-literal source without emitting delimiters.
+ Encode a value, including quotes, without Jinja template delimiters.
Every code point is escaped so HTML parsing and sanitizer normalization cannot
turn data into Jinja syntax before the template is compiled.
@@ -61,6 +62,36 @@ class ReportSandboxedEnvironment(jinja2.sandbox.ImmutableSandboxedEnvironment):
"docxtpl.",
)
+ _safe_builtin_method_owner_types = (
+ bool,
+ bytes,
+ date,
+ datetime,
+ dict,
+ float,
+ frozenset,
+ int,
+ list,
+ range,
+ set,
+ str,
+ time,
+ timedelta,
+ tuple,
+ )
+
+ _safe_jinja_method_owner_types = (
+ jinja2.runtime.AsyncLoopContext,
+ jinja2.runtime.LoopContext,
+ jinja2.utils.Cycler,
+ )
+
+ _safe_jinja_callable_types = (
+ jinja2.runtime.BlockReference,
+ jinja2.runtime.Macro,
+ jinja2.utils.Joiner,
+ )
+
@classmethod
def _is_blocked_class(cls, obj):
"""Return whether obj is a class for one of the blocked capability types."""
@@ -79,7 +110,7 @@ def _is_blocked_object(cls, obj):
)
def _is_registered_callable(self, obj):
- """Return whether a Python function or class was explicitly registered."""
+ """Return whether a callable was explicitly registered with the environment."""
return any(
obj is candidate
for registry in (self.globals, self.filters, self.tests)
@@ -93,6 +124,25 @@ def _is_registered_callable(self, obj):
)
)
+ @classmethod
+ def _is_safe_builtin_method(cls, obj):
+ """
+ Return whether obj is an inherited method of a known data-only type.
+
+ Looking up the defining class prevents a dict or string subclass from
+ introducing an executable method and inheriting trust from its base type.
+ """
+ if not isinstance(obj, types.BuiltinMethodType):
+ return False
+ owner = obj.__self__
+ method_name = getattr(obj, "__name__", None)
+ if owner is None or method_name is None:
+ return False
+ for base in type(owner).__mro__:
+ if method_name in base.__dict__:
+ return base in cls._safe_builtin_method_owner_types
+ return False
+
def is_safe_attribute(self, obj, attr, value):
"""Deny access to application-marked objects and Jinja internals."""
if self._is_blocked_object(obj) or isinstance(
@@ -103,7 +153,7 @@ def is_safe_attribute(self, obj, attr, value):
return super().is_safe_attribute(obj, attr, value)
def is_safe_callable(self, obj):
- """Deny calls on Jinja capability objects as defense in depth."""
+ """Permit only registered functions and explicitly data-only call targets."""
owner = (
obj.__self__
if isinstance(
@@ -117,16 +167,18 @@ def is_safe_callable(self, obj):
)
if self._is_blocked_object(obj) or self._is_blocked_object(owner):
return False
- if isinstance(
- obj,
- (
- types.FunctionType,
- types.BuiltinFunctionType,
- type,
- ),
+ if self._is_registered_callable(obj):
+ return super().is_safe_callable(obj)
+ if self._is_safe_builtin_method(obj):
+ return super().is_safe_callable(obj)
+ if (
+ isinstance(obj, types.MethodType)
+ and type(owner) in self._safe_jinja_method_owner_types
):
- return self._is_registered_callable(obj)
- return super().is_safe_callable(obj)
+ return super().is_safe_callable(obj)
+ if type(obj) in self._safe_jinja_callable_types:
+ return super().is_safe_callable(obj)
+ return False
def prepare_jinja2_env(debug=False):
@@ -187,9 +239,9 @@ def report_generation_queryset():
"""
Gets a queryset of Reports with `select_related` and `prefetch_related` options optimal for report generation.
"""
- from ghostwriter.reporting.models import (
+ from ghostwriter.reporting.models import ( # pylint: disable=import-outside-toplevel
Report,
- ) # pylint: disable=import-outside-toplevel
+ )
return (
Report.objects.all()
diff --git a/ghostwriter/modules/reportwriter/base/docx.py b/ghostwriter/modules/reportwriter/base/docx.py
index 8c73127ad..53790df29 100644
--- a/ghostwriter/modules/reportwriter/base/docx.py
+++ b/ghostwriter/modules/reportwriter/base/docx.py
@@ -294,7 +294,9 @@ def render_properties(self, context: dict):
continue
out = ReportExportTemplateError.map_errors(
- lambda: self.jinja_env.from_string(template_src).render(context),
+ lambda template_src=template_src: self.jinja_env.from_string(
+ template_src
+ ).render(context),
f"DOCX property {attr}",
)
setattr(self.word_doc.core_properties, attr, out)
diff --git a/ghostwriter/modules/reportwriter/base/html_rich_text.py b/ghostwriter/modules/reportwriter/base/html_rich_text.py
index 12279c496..761566156 100644
--- a/ghostwriter/modules/reportwriter/base/html_rich_text.py
+++ b/ghostwriter/modules/reportwriter/base/html_rich_text.py
@@ -1,4 +1,5 @@
# Standard Libraries
+import html
import re
import secrets
from abc import ABC, abstractmethod
@@ -15,6 +16,38 @@
_H = [f"h{n}" for n in range(1, 7)]
JINJA_LITERAL_ATTRIBUTE = "data-gw-jinja-literal"
+JINJA_REFERENCE_ENCODED_ATTRIBUTE = "data-gw-ref-encoded"
+_JINJA_REFERENCE_ATTRIBUTE_PATTERN = re.compile(
+ rf"(?P\s){JINJA_REFERENCE_ENCODED_ATTRIBUTE}\s*=\s*"
+ r"(?P[\"'])(?P[0-9a-fA-F-]*)(?P=quote)",
+ re.IGNORECASE,
+)
+
+
+def _decode_jinja_reference_attributes(text: str) -> str:
+ """
+ Restore inert editor reference attributes after Jinja has finished rendering.
+
+ The editor persists each reference name as hexadecimal Unicode code points, so
+ even a malicious evidence name cannot introduce Jinja delimiters into template
+ source.
+ """
+
+ def replace_reference(match: re.Match) -> str:
+ try:
+ ref_name = "".join(
+ chr(int(code_point, 16))
+ for code_point in match.group("value").split("-")
+ if code_point
+ )
+ except ValueError:
+ return match.group(0)
+ return (
+ f'{match.group("space")}data-gw-ref="'
+ f'{html.escape(ref_name, quote=True)}"'
+ )
+
+ return _JINJA_REFERENCE_ATTRIBUTE_PATTERN.sub(replace_reference, text)
def _require_report_sandbox(template_or_environment):
@@ -46,6 +79,7 @@ def __init__(self, template: jinja2.Template, literal_fragments: dict[str, str])
def render(self, *args, **kwargs):
rendered = self._template.render(*args, **kwargs)
+ rendered = _decode_jinja_reference_attributes(rendered)
for placeholder, literal_html in self._literal_fragments.items():
rendered = rendered.replace(placeholder, literal_html)
return rendered
@@ -53,11 +87,11 @@ def render(self, *args, **kwargs):
def _extract_jinja_literal_fragments(text: str) -> tuple[str, dict[str, str]]:
"""
- Replace marked HTML containers with inert placeholders before Jinja parsing.
+ Replace literal containers and stored references before Jinja parsing.
The marker container itself is intentionally omitted from the rendered output;
only its contents are restored. Nested markers are handled by their outermost
- marked ancestor.
+ marked ancestor. Legacy reference nodes are also data, never template source.
"""
soup = bs4.BeautifulSoup(text, "html.parser")
literal_fragments = {}
@@ -66,7 +100,14 @@ def _extract_jinja_literal_fragments(text: str) -> tuple[str, dict[str, str]]:
continue
placeholder = f"GWJINJALITERAL{secrets.token_hex(24)}"
- literal_fragments[placeholder] = node.decode_contents()
+ literal_fragments[placeholder] = _decode_jinja_reference_attributes(
+ node.decode_contents()
+ )
+ node.replace_with(placeholder)
+
+ for node in soup.find_all(attrs={"data-gw-ref": True}):
+ placeholder = f"GWJINJALITERAL{secrets.token_hex(24)}"
+ literal_fragments[placeholder] = str(node)
node.replace_with(placeholder)
return str(soup), literal_fragments
@@ -259,7 +300,9 @@ def render_html(self):
)
self._rendering = True
try:
- self._rendered = Markup(
+ # Rich-text HTML is intentionally preserved for the document
+ # converters; input fields are sanitized before reaching here.
+ self._rendered = Markup( # nosec B704
ReportExportTemplateError.map_errors(
lambda: self._template.render(self._context),
self.location,
diff --git a/ghostwriter/reporting/tests/test_rich_text_templating.py b/ghostwriter/reporting/tests/test_rich_text_templating.py
index 58625836c..13d2ba82c 100644
--- a/ghostwriter/reporting/tests/test_rich_text_templating.py
+++ b/ghostwriter/reporting/tests/test_rich_text_templating.py
@@ -1,4 +1,5 @@
# Standard Libraries
+from io import StringIO
from pathlib import Path
from tempfile import TemporaryDirectory
@@ -152,6 +153,30 @@ def test_registered_globals_and_template_macros_remain_callable(self):
self.assertEqual(template.render(), "[0][1][2]odd-even|a,b")
+ def test_data_only_builtin_methods_remain_callable(self):
+ env = prepare_jinja2_env()
+ template = env.from_string(
+ '{{ values.get("name").upper() }}|'
+ "{% for key, value in values.items()|sort %}"
+ "{{ key }}={{ value }};"
+ "{% endfor %}"
+ )
+
+ self.assertEqual(
+ template.render(values={"name": "ghostwriter", "count": 2}),
+ "GHOSTWRITER|count=2;name=ghostwriter;",
+ )
+
+ def test_jinja_loop_and_block_helpers_remain_callable(self):
+ env = prepare_jinja2_env()
+ template = env.from_string(
+ "{% block content %}content{% endblock %}|"
+ "{{ self.content() }}|"
+ "{% for number in range(2) %}{{ loop.cycle('a', 'b') }}{% endfor %}"
+ )
+
+ self.assertEqual(template.render(), "content|content|ab")
+
def test_unregistered_python_callable_is_blocked(self):
env = prepare_jinja2_env()
@@ -168,6 +193,42 @@ def unregistered(value):
)
self.assertIn('data-gw-ref="Example"', rendered)
+ def test_unregistered_bound_method_and_callable_object_are_blocked(self):
+ env = prepare_jinja2_env()
+
+ class Capability:
+ def execute(self):
+ return "executed"
+
+ def __call__(self):
+ return "called"
+
+ capability = Capability()
+ for payload in ("{{ capability.execute() }}", "{{ capability() }}"):
+ with self.subTest(payload=payload), self.assertRaises(SecurityError):
+ env.from_string(payload).render(capability=capability)
+
+ sink = StringIO()
+ with self.assertRaises(SecurityError):
+ env.from_string("{{ sink.write('executed') }}").render(sink=sink)
+ self.assertEqual(sink.getvalue(), "")
+
+ with self.assertRaises(SecurityError):
+ env.from_string("{{ operation(values) }}").render(
+ operation=len,
+ values=[],
+ )
+
+ def test_data_type_subclass_cannot_introduce_callable_methods(self):
+ env = prepare_jinja2_env()
+
+ class ExecutableDict(dict):
+ def execute(self):
+ return "executed"
+
+ with self.assertRaises(SecurityError):
+ env.from_string("{{ values.execute() }}").render(values=ExecutableDict())
+
def test_python_callable_attributes_are_blocked(self):
env = prepare_jinja2_env()
@@ -291,6 +352,53 @@ def test_marked_oplog_content_survives_html_entity_normalization(self):
self.assertIn("safe}}CLIENT={{ client.name }}{{.ref safe", rendered)
self.assertNotIn("Rendered Client", rendered)
+ def test_encoded_reference_is_decoded_only_after_jinja_rendering(self):
+ env = prepare_jinja2_env()
+ ref_name = "safe}}CLIENT={{ client.name }}{{.ref safe"
+ encoded_ref = "-".join(f"{ord(character):x}" for character in ref_name)
+ encoded_node = f'
'
+
+ for source in (
+ encoded_node + "{{ client.name }}
",
+ f'{encoded_node}
'
+ "{{ client.name }}
",
+ ):
+ with self.subTest(source=source):
+ rendered = rich_text_template(env, source).render(
+ client={"name": "Rendered Client"}
+ )
+
+ self.assertIn(
+ 'data-gw-ref="safe}}CLIENT={{ client.name }}{{.ref safe"',
+ rendered,
+ )
+ self.assertNotIn("data-gw-ref-encoded", rendered)
+ self.assertEqual(rendered.count("Rendered Client"), 1)
+
+ def test_legacy_reference_node_is_always_literal_data(self):
+ env = prepare_jinja2_env()
+ source = (
+ ''
+ "{{ client.name }}"
+ "
"
+ "{{ client.name }}
"
+ )
+
+ rendered = rich_text_template(env, source).render(
+ client={"name": "Rendered Client"}
+ )
+
+ self.assertIn("{{ client.name }}", rendered)
+ self.assertEqual(rendered.count("Rendered Client"), 1)
+
+ def test_invalid_encoded_reference_remains_inert(self):
+ env = prepare_jinja2_env()
+ source = ' '
+
+ rendered = rich_text_template(env, source).render()
+
+ self.assertIn('data-gw-ref-encoded="110000"', rendered)
+
def test_debug_extension_ast_escape_is_blocked(self):
payload = """
{% set e=project.description_rt.template.environment %}
diff --git a/ghostwriter/reporting/views2/report.py b/ghostwriter/reporting/views2/report.py
index 479c44ad0..951708f3b 100644
--- a/ghostwriter/reporting/views2/report.py
+++ b/ghostwriter/reporting/views2/report.py
@@ -5,7 +5,6 @@
import os
import logging
import mimetypes
-import re
import zipfile
from socket import gaierror
from asgiref.sync import async_to_sync
diff --git a/javascript/src/tiptap_gw/jinja_literal.ts b/javascript/src/tiptap_gw/jinja_literal.ts
index 0248aa868..875d60590 100644
--- a/javascript/src/tiptap_gw/jinja_literal.ts
+++ b/javascript/src/tiptap_gw/jinja_literal.ts
@@ -1,5 +1,33 @@
import { mergeAttributes, Node } from "@tiptap/core";
+const ENCODED_REFERENCE_ATTRIBUTE = "data-gw-ref-encoded";
+
+function encodeReference(ref: string): string {
+ return Array.from(ref)
+ .map((character) => character.codePointAt(0)!.toString(16))
+ .join("-");
+}
+
+function decodeReference(encodedRef: string): string {
+ try {
+ return encodedRef
+ .split("-")
+ .filter(Boolean)
+ .map((codePoint) => String.fromCodePoint(parseInt(codePoint, 16)))
+ .join("");
+ } catch {
+ return "";
+ }
+}
+
+function referenceFromElement(element: HTMLElement): string {
+ const encodedRef = element.getAttribute(ENCODED_REFERENCE_ATTRIBUTE);
+ if (encodedRef !== null) {
+ return decodeReference(encodedRef);
+ }
+ return element.getAttribute("data-gw-ref") ?? "";
+}
+
/**
* Marks generated report content as literal data.
*
@@ -30,8 +58,8 @@ const JinjaLiteral = Node.create({
/**
* A structured cross-reference used by generated oplog outlines.
*
- * Keeping the reference name in a node attribute avoids constructing legacy
- * `{{.ref ...}}` template source from an evidence friendly name.
+ * The serialized attribute contains only hexadecimal code points. The visible
+ * legacy syntax is supplied by a node view and never becomes template source.
*/
export const JinjaReference = Node.create({
name: "jinjaReference",
@@ -43,25 +71,38 @@ export const JinjaReference = Node.create({
return {
ref: {
default: "",
- parseHTML: (element) =>
- element.getAttribute("data-gw-ref") ?? "",
+ parseHTML: referenceFromElement,
renderHTML: (attributes) => ({
- "data-gw-ref": attributes.ref,
+ [ENCODED_REFERENCE_ATTRIBUTE]: encodeReference(
+ String(attributes.ref)
+ ),
}),
},
};
},
parseHTML() {
- return [{ tag: "span[data-gw-ref]" }];
+ return [
+ { tag: `span[${ENCODED_REFERENCE_ATTRIBUTE}]` },
+ { tag: "span[data-gw-ref]" },
+ ];
},
renderText({ node }) {
- return `{{.ref ${node.attrs.ref}}}`;
+ return String(node.attrs.ref);
},
- renderHTML({ node, HTMLAttributes }) {
- return ["span", HTMLAttributes, `{{.ref ${node.attrs.ref}}}`];
+ renderHTML({ HTMLAttributes }) {
+ return ["span", HTMLAttributes];
+ },
+
+ addNodeView() {
+ return ({ node }) => {
+ const dom = document.createElement("span");
+ dom.contentEditable = "false";
+ dom.textContent = `{{.ref ${node.attrs.ref}}}`;
+ return { dom };
+ };
},
});
From b558f5765e96ac522f5605d9603d7ce20c0e124c Mon Sep 17 00:00:00 2001
From: Christopher Maddalena
Date: Fri, 31 Jul 2026 07:33:42 -0700
Subject: [PATCH 5/6] Treat report markers as literal data
---
.../reportwriter/base/html_rich_text.py | 32 ++++++++++++++++---
.../tests/test_rich_text_templating.py | 25 +++++++++++++++
javascript/src/tiptap_gw/jinja_literal.ts | 18 ++++++++---
3 files changed, 66 insertions(+), 9 deletions(-)
diff --git a/ghostwriter/modules/reportwriter/base/html_rich_text.py b/ghostwriter/modules/reportwriter/base/html_rich_text.py
index 761566156..fca1b4a81 100644
--- a/ghostwriter/modules/reportwriter/base/html_rich_text.py
+++ b/ghostwriter/modules/reportwriter/base/html_rich_text.py
@@ -1,5 +1,5 @@
# Standard Libraries
-import html
+import html as html_lib
import re
import secrets
from abc import ABC, abstractmethod
@@ -17,6 +17,12 @@
_H = [f"h{n}" for n in range(1, 7)]
JINJA_LITERAL_ATTRIBUTE = "data-gw-jinja-literal"
JINJA_REFERENCE_ENCODED_ATTRIBUTE = "data-gw-ref-encoded"
+JINJA_LITERAL_VALUE_ATTRIBUTES = (
+ "data-evidence-id",
+ "data-gw-caption",
+ "data-gw-evidence",
+ "data-gw-image",
+)
_JINJA_REFERENCE_ATTRIBUTE_PATTERN = re.compile(
rf"(?P\s){JINJA_REFERENCE_ENCODED_ATTRIBUTE}\s*=\s*"
r"(?P[\"'])(?P[0-9a-fA-F-]*)(?P=quote)",
@@ -44,7 +50,7 @@ def replace_reference(match: re.Match) -> str:
return match.group(0)
return (
f'{match.group("space")}data-gw-ref="'
- f'{html.escape(ref_name, quote=True)}"'
+ f'{html_lib.escape(ref_name, quote=True)}"'
)
return _JINJA_REFERENCE_ATTRIBUTE_PATTERN.sub(replace_reference, text)
@@ -91,7 +97,8 @@ def _extract_jinja_literal_fragments(text: str) -> tuple[str, dict[str, str]]:
The marker container itself is intentionally omitted from the rendered output;
only its contents are restored. Nested markers are handled by their outermost
- marked ancestor. Legacy reference nodes are also data, never template source.
+ marked ancestor. Legacy reference nodes and structural marker values are data,
+ never template source.
"""
soup = bs4.BeautifulSoup(text, "html.parser")
literal_fragments = {}
@@ -109,6 +116,20 @@ def _extract_jinja_literal_fragments(text: str) -> tuple[str, dict[str, str]]:
placeholder = f"GWJINJALITERAL{secrets.token_hex(24)}"
literal_fragments[placeholder] = str(node)
node.replace_with(placeholder)
+
+ for node in soup.find_all(True):
+ for attribute in JINJA_LITERAL_VALUE_ATTRIBUTES:
+ if attribute not in node.attrs:
+ continue
+ placeholder = f"GWJINJALITERAL{secrets.token_hex(24)}"
+ attribute_value = node.attrs[attribute]
+ if isinstance(attribute_value, list):
+ attribute_value = " ".join(attribute_value)
+ literal_fragments[placeholder] = html_lib.escape(
+ str(attribute_value),
+ quote=True,
+ )
+ node.attrs[attribute] = placeholder
return str(soup), literal_fragments
@@ -134,8 +155,9 @@ def rich_text_template(
text: str,
) -> CompiledRichTextTemplate:
"""
- Converts rich text `text` to a Jinja template. This does some additional Ghostwriter-specific
- processing.
+ Compile rich text into Ghostwriter's sandboxed rich-text template wrapper.
+
+ Literal data is excluded from Jinja parsing and restored after rendering.
"""
_require_report_sandbox(env)
diff --git a/ghostwriter/reporting/tests/test_rich_text_templating.py b/ghostwriter/reporting/tests/test_rich_text_templating.py
index 13d2ba82c..e7632247f 100644
--- a/ghostwriter/reporting/tests/test_rich_text_templating.py
+++ b/ghostwriter/reporting/tests/test_rich_text_templating.py
@@ -391,6 +391,31 @@ def test_legacy_reference_node_is_always_literal_data(self):
self.assertIn("{{ client.name }}", rendered)
self.assertEqual(rendered.count("Rendered Client"), 1)
+ def test_structural_marker_values_are_always_literal_data(self):
+ env = prepare_jinja2_env()
+ marker_value = "safe}}CLIENT={{ client.name }}{{.ref safe"
+
+ for attribute in (
+ "data-evidence-id",
+ "data-gw-caption",
+ "data-gw-evidence",
+ "data-gw-image",
+ ):
+ with self.subTest(attribute=attribute):
+ source = (
+ f''
+ "Caption for {{ client.name }}"
+ "
"
+ )
+
+ rendered = rich_text_template(env, source).render(
+ client={"name": "Rendered Client"}
+ )
+
+ self.assertIn(f'{attribute}="{marker_value}"', rendered)
+ self.assertIn("Caption for Rendered Client", rendered)
+ self.assertEqual(rendered.count("Rendered Client"), 1)
+
def test_invalid_encoded_reference_remains_inert(self):
env = prepare_jinja2_env()
source = ' '
diff --git a/javascript/src/tiptap_gw/jinja_literal.ts b/javascript/src/tiptap_gw/jinja_literal.ts
index 875d60590..37732f344 100644
--- a/javascript/src/tiptap_gw/jinja_literal.ts
+++ b/javascript/src/tiptap_gw/jinja_literal.ts
@@ -8,7 +8,10 @@ function encodeReference(ref: string): string {
.join("-");
}
-function decodeReference(encodedRef: string): string {
+function decodeReference(encodedRef: string): string | null {
+ if (!/^(?:[0-9a-f]+(?:-[0-9a-f]+)*)?$/i.test(encodedRef)) {
+ return null;
+ }
try {
return encodedRef
.split("-")
@@ -16,16 +19,23 @@ function decodeReference(encodedRef: string): string {
.map((codePoint) => String.fromCodePoint(parseInt(codePoint, 16)))
.join("");
} catch {
- return "";
+ return null;
}
}
function referenceFromElement(element: HTMLElement): string {
+ const legacyRef = element.getAttribute("data-gw-ref");
const encodedRef = element.getAttribute(ENCODED_REFERENCE_ATTRIBUTE);
if (encodedRef !== null) {
- return decodeReference(encodedRef);
+ const decodedRef = decodeReference(encodedRef);
+ if (decodedRef) {
+ return decodedRef;
+ }
+ if (legacyRef === null) {
+ return decodedRef ?? "";
+ }
}
- return element.getAttribute("data-gw-ref") ?? "";
+ return legacyRef ?? "";
}
/**
From f7ddb28bc1866b058b772bb1208b04918b052fc7 Mon Sep 17 00:00:00 2001
From: Christopher Maddalena
Date: Fri, 31 Jul 2026 11:09:35 -0700
Subject: [PATCH 6/6] Fixed collision that affected blockquotes in pptx
---
CHANGELOG.md | 1 +
.../modules/reportwriter/richtext/pptx.py | 28 +++++---
.../reporting/tests/test_rich_text_pptx.py | 67 +++++++++++++++++++
3 files changed, 87 insertions(+), 9 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5686bf43d..3afe04e9a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* This fixed a typo in the path, but had no effect on functionality
* Fixed activity-log sanitization confirmation and scoped its CSRF header to its own request
* Fixed expired API and service token feedback so the appropriate token row and empty state are updated in the UI
+* Fixed PowerPoint report generation when rich-text table cells contain blockquotes or preformatted text
### Security
diff --git a/ghostwriter/modules/reportwriter/richtext/pptx.py b/ghostwriter/modules/reportwriter/richtext/pptx.py
index 27a5a5fe6..4edf4a886 100644
--- a/ghostwriter/modules/reportwriter/richtext/pptx.py
+++ b/ghostwriter/modules/reportwriter/richtext/pptx.py
@@ -108,14 +108,19 @@ def tag_p(self, el, par=None, **kwargs):
self.process_children(el, par=par, **kwargs)
- def tag_pre(self, el, **kwargs):
- top = Inches(1.65)
- left = Inches(8)
- width = Inches(4.5)
- height = Inches(3)
- textbox = self.slide.shapes.add_textbox(left, top, width, height)
+ def tag_pre(self, el, *, par=None, **kwargs):
+ if par is None:
+ top = Inches(1.65)
+ left = Inches(8)
+ width = Inches(4.5)
+ height = Inches(3)
+ textbox = self.slide.shapes.add_textbox(left, top, width, height)
+ par = textbox.text_frame.add_paragraph()
+ elif any(run.text for run in par.runs):
+ # Keep nested preformatted text in its current text frame, such as
+ # a table cell, instead of creating a detached slide textbox.
+ par = par._parent.add_paragraph()
self.text_tracking.new_block()
- par = textbox.text_frame.add_paragraph()
par.alignment = PP_ALIGN.LEFT
if len(el.contents) == 1 and el.contents[0].name == "code":
@@ -133,8 +138,13 @@ def tag_pre(self, el, **kwargs):
run.font.size = Pt(11)
run.font.name = "Courier New"
- def tag_blockquote(self, el, **kwargs):
- par = self.shape.text_frame.add_paragraph()
+ def tag_blockquote(self, el, *, par=None, **kwargs):
+ if par is None:
+ par = self.shape.text_frame.add_paragraph()
+ elif any(run.text for run in par.runs):
+ # Keep a nested blockquote in its current text frame, including
+ # when that frame belongs to a table cell.
+ par = par._parent.add_paragraph()
self.text_tracking.new_block()
self.process_children(el.children, par=par, **kwargs)
diff --git a/ghostwriter/reporting/tests/test_rich_text_pptx.py b/ghostwriter/reporting/tests/test_rich_text_pptx.py
index 21da566e0..32516e970 100644
--- a/ghostwriter/reporting/tests/test_rich_text_pptx.py
+++ b/ghostwriter/reporting/tests/test_rich_text_pptx.py
@@ -95,6 +95,73 @@ def test_func(self):
class RichTextToPptxTests(SimpleTestCase):
maxDiff = None
+ def test_blockquote_in_table_cell_is_rendered(self):
+ ppt = pptx.Presentation()
+ slide = ppt.slides.add_slide(ppt.slide_layouts[SLD_LAYOUT_TITLE_AND_CONTENT])
+ shape = slide.shapes.placeholders[1]
+ shape.text_frame.clear()
+
+ HtmlToPptx.run(
+ """
+
+
+
+
+ Before quote
+ Quoted text
+
+
+
+
+ """,
+ slide,
+ shape,
+ )
+
+ table = next(
+ slide_shape.table for slide_shape in slide.shapes if slide_shape.has_table
+ )
+ self.assertEqual(
+ [paragraph.text for paragraph in table.cell(0, 0).text_frame.paragraphs],
+ ["Before quote", "Quoted text"],
+ )
+
+ def test_preformatted_text_in_table_cell_is_rendered(self):
+ ppt = pptx.Presentation()
+ slide = ppt.slides.add_slide(ppt.slide_layouts[SLD_LAYOUT_TITLE_AND_CONTENT])
+ shape = slide.shapes.placeholders[1]
+ shape.text_frame.clear()
+
+ HtmlToPptx.run(
+ """
+
+
+
+
+ Command output
+ first line\nsecond line
+
+
+
+
+ """,
+ slide,
+ shape,
+ )
+
+ table = next(
+ slide_shape.table for slide_shape in slide.shapes if slide_shape.has_table
+ )
+ paragraphs = table.cell(0, 0).text_frame.paragraphs
+ self.assertEqual(
+ [paragraph.text for paragraph in paragraphs],
+ ["Command output", "first line\nsecond line"],
+ )
+ self.assertEqual(
+ {run.font.name for run in paragraphs[1].runs},
+ {"Courier New"},
+ )
+
def test_trailing_empty_paragraph_after_list_is_omitted(self):
ppt = pptx.Presentation()
slide = ppt.slides.add_slide(ppt.slide_layouts[SLD_LAYOUT_TITLE_AND_CONTENT])