Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -19,9 +19,14 @@ 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

* 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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
v7.2.5
27 July 2026
31 July 2026
2 changes: 1 addition & 1 deletion config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
81 changes: 79 additions & 2 deletions ghostwriter/api/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Standard Libraries
import base64
import copy
import json
import logging
import os
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
"<p>Here is some evidence:</p><p>{{.New Name}}</p><p>{{.ref New Name}}</p>",
f"<p>Here is some evidence:</p><p>{inline_evidence}</p>"
f"<p>{evidence_reference}</p>",
)
self.assertEqual(
self.finding.impact,
"<p>Here is some evidence:</p><p>{{.New Name}}</p><p>{{.ref New Name}}</p>",
f"<p>Here is some evidence:</p><p>{inline_evidence}</p>"
f"<p>{evidence_reference}</p>",
)

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(
Expand Down
27 changes: 25 additions & 2 deletions ghostwriter/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -2011,9 +2022,21 @@ def post(self, request, *args, **kwargs):
if self.event["op"] == "DELETE":
new = current.replace(f"<p>{prev_friendly}</p>", "")
new = new.replace(prev_friendly_ref, "")
new = new.replace(
f"<p>{prev_encoded_friendly}</p>", ""
)
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:
Expand Down
Loading
Loading