Skip to content

Commit 7ceb558

Browse files
Merge pull request #919 from GhostManager/hotfix/template-swap
Hotfix: Template swap security
2 parents b72e2fb + 0dcfc05 commit 7ceb558

17 files changed

Lines changed: 603 additions & 50 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [7.1.2] - 24 June 2026
11+
12+
### Fixed
13+
14+
* Fixed `datetime-local` rendering for white card and deconfliction edit forms so saved timestamps display reliably in older browsers (Closes #917)
15+
16+
### Security
17+
18+
* Fixed additional client-scoped report template authorization bypasses in template swapping, report generation, archive generation, linting, and lint result endpoints
19+
* Report template selection now only accepts global templates or templates scoped to the report project's client
20+
* This fix includes two temporary breaking changes for the API while we work on a custom endpoint to handle this new business logic:
21+
* **Breaking:** The GraphQL API no longer allows `user` or `manager` roles to set report template ID columns directly when creating or updating reports
22+
* **Breaking:** The GraphQL API no longer allows `user` or `manager` roles to update a report's project ID column directly
23+
1024
## [7.1.1] - 18 June 2026
1125

1226
### Fixed

VERSION

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
v7.1.1
2-
18 June 2026
1+
v7.1.2
2+
24 June 2026

config/settings/base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
# 3rd Party Libraries
1212
import environ
1313

14-
__version__ = "7.1.1"
14+
__version__ = "7.1.2"
1515
VERSION = __version__
16-
RELEASE_DATE = "18 June 2026"
16+
RELEASE_DATE = "24 June 2026"
1717

1818
ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent.parent
1919
APPS_DIR = ROOT_DIR / "ghostwriter"

ghostwriter/api/tests/test_hasura_metadata.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -735,3 +735,24 @@ def test_library_write_permissions_require_user_feature_flags(self):
735735
user_feature_flag_check(feature_flag),
736736
f"{filename} {permission_type}",
737737
)
738+
739+
def test_report_template_assignments_are_not_graphql_writable(self):
740+
table = load_yaml(HASURA_TABLE_DIR / "public_reporting_report.yaml")
741+
template_columns = {"docx_template_id", "pptx_template_id"}
742+
743+
for role in ("manager", "user"):
744+
for permission_type in ("insert_permissions", "update_permissions"):
745+
permission = get_role_permission(table, role, permission_type)
746+
writable_columns = set(permission["permission"].get("columns", []))
747+
self.assertFalse(
748+
template_columns & writable_columns,
749+
f"{role} {permission_type}",
750+
)
751+
752+
def test_report_project_is_not_graphql_updateable(self):
753+
table = load_yaml(HASURA_TABLE_DIR / "public_reporting_report.yaml")
754+
755+
for role in ("manager", "user"):
756+
permission = get_role_permission(table, role, "update_permissions")
757+
writable_columns = set(permission["permission"].get("columns", []))
758+
self.assertNotIn("project_id", writable_columns, role)

ghostwriter/commandcenter/forms.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ def __init__(self, *args, **kwargs):
3737
def clean_default_docx_template(self):
3838
docx_template = self.cleaned_data["default_docx_template"]
3939
if docx_template:
40+
if docx_template.client_id is not None:
41+
raise ValidationError(
42+
_("Global default Word templates cannot be scoped to a client"),
43+
"invalid",
44+
)
4045
docx_template_status = docx_template.get_status()
4146
if docx_template_status in ("error", "failed"):
4247
raise ValidationError(
@@ -48,6 +53,11 @@ def clean_default_docx_template(self):
4853
def clean_default_pptx_template(self):
4954
pptx_template = self.cleaned_data["default_pptx_template"]
5055
if pptx_template:
56+
if pptx_template.client_id is not None:
57+
raise ValidationError(
58+
_("Global default PowerPoint templates cannot be scoped to a client"),
59+
"invalid",
60+
)
5161
pptx_template_status = pptx_template.get_status()
5262
if pptx_template_status in ("error", "failed"):
5363
raise ValidationError(

ghostwriter/commandcenter/tests/test_forms.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from ghostwriter.commandcenter.models import ExtraFieldModel, ExtraFieldSpec
1212
from ghostwriter.commandcenter.forms import ExtraFieldsField, ExtraFieldsWidget, ReportConfigurationForm
1313
from ghostwriter.factories import (
14+
ClientFactory,
1415
ExtraFieldSpecFactory,
1516
ReportConfigurationFactory,
1617
ReportDocxTemplateFactory,
@@ -127,6 +128,13 @@ def test_clean_default_docx_template(self):
127128
self.assertEqual(len(errors), 1)
128129
self.assertEqual(errors[0].code, "invalid")
129130

131+
config["default_docx_template_id"] = ReportDocxTemplateFactory(client=ClientFactory()).pk
132+
133+
form = self.form_data(**config)
134+
errors = form.errors["default_docx_template"].as_data()
135+
self.assertEqual(len(errors), 1)
136+
self.assertEqual(errors[0].code, "invalid_choice")
137+
130138
def test_clean_default_pptx_template(self):
131139
config = self.config.__dict__.copy()
132140
form = self.form_data(**config)
@@ -140,6 +148,13 @@ def test_clean_default_pptx_template(self):
140148
self.assertEqual(len(errors), 1)
141149
self.assertEqual(errors[0].code, "invalid")
142150

151+
config["default_pptx_template_id"] = ReportPptxTemplateFactory(client=ClientFactory()).pk
152+
153+
form = self.form_data(**config)
154+
errors = form.errors["default_pptx_template"].as_data()
155+
self.assertEqual(len(errors), 1)
156+
self.assertEqual(errors[0].code, "invalid_choice")
157+
143158
def test_clean_outline_tags_normalizes_and_deduplicates_rules(self):
144159
config = self.config.__dict__.copy()
145160
config["outline_tags"] = " report , EVIDENCE, cred* , att&ck: , cred* , ATT&CK:* ,, "

ghostwriter/reporting/archive.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ def archive_report(report: Report):
2424
raise MissingTemplate()
2525
if not pptx_template:
2626
raise MissingTemplate()
27+
if not docx_template.can_apply_to_report(report, "docx"):
28+
raise ValueError("The selected Word template is not available for this report.")
29+
if not pptx_template.can_apply_to_report(report, "pptx"):
30+
raise ValueError("The selected PowerPoint template is not available for this report.")
2731
filename = "archives/" + "".join(c for c in report.title if c.isalpha() or c.isdigit() or c == ' ') + ".zip"
2832
evidences = report.all_evidences()
2933

ghostwriter/reporting/forms.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@
4545
)
4646
from ghostwriter.rolodex.models import Project
4747

48+
49+
def _report_template_queryset(doc_type, project=None):
50+
queryset = ReportTemplate.objects.filter(
51+
doc_type__doc_type__iexact=doc_type,
52+
).select_related("doc_type", "client")
53+
if project:
54+
return queryset.filter(Q(client_id=project.client_id) | Q(client__isnull=True))
55+
return queryset.filter(client__isnull=True)
56+
57+
4858
class AssignReportFindingForm(forms.ModelForm):
4959
class Meta:
5060
model = ReportFindingLink
@@ -131,6 +141,17 @@ def __init__(self, user=None, project=None, *args, **kwargs):
131141
lambda obj: f"{obj.start_date} {obj.client.name} {obj.project_type} ({obj.codename})"
132142
)
133143

144+
selected_project = project
145+
if selected_project is None and getattr(self.instance, "project_id", None):
146+
selected_project = self.instance.project
147+
if self.is_bound and not self.fields["project"].disabled:
148+
project_id = self.data.get(self.add_prefix("project"))
149+
if project_id:
150+
try:
151+
selected_project = self.fields["project"].queryset.filter(pk=project_id).first()
152+
except (TypeError, ValueError):
153+
selected_project = None
154+
134155
for field in self.fields:
135156
self.fields[field].widget.attrs["autocomplete"] = "off"
136157
self.fields["docx_template"].label = "DOCX Template"
@@ -141,8 +162,17 @@ def __init__(self, user=None, project=None, *args, **kwargs):
141162
self.fields["title"].widget.attrs["placeholder"] = "Red Team Report for Project Foo"
142163

143164
report_config = ReportConfiguration.get_solo()
144-
self.fields["docx_template"].initial = report_config.default_docx_template
145-
self.fields["pptx_template"].initial = report_config.default_pptx_template
165+
template_fields = (
166+
("docx_template", "docx", report_config.default_docx_template),
167+
("pptx_template", "pptx", report_config.default_pptx_template),
168+
)
169+
for field_name, doc_type, default_template in template_fields:
170+
self.fields[field_name].queryset = _report_template_queryset(doc_type, selected_project)
171+
if default_template and (
172+
default_template.client_id is None
173+
or (selected_project and default_template.can_apply_to_project(selected_project))
174+
):
175+
self.fields[field_name].initial = default_template
146176
self.fields["docx_template"].empty_label = "-- Pick a Word Template --"
147177
self.fields["pptx_template"].empty_label = "-- Pick a PowerPoint Template --"
148178

@@ -575,6 +605,8 @@ def __init__(self, *args, **kwargs):
575605
self.fields["docx_template"].required = False
576606
self.fields["pptx_template"].help_text = None
577607
self.fields["pptx_template"].required = False
608+
self.fields["docx_template"].queryset = _report_template_queryset("docx", self.instance.project)
609+
self.fields["pptx_template"].queryset = _report_template_queryset("pptx", self.instance.project)
578610
self.fields["docx_template"].empty_label = "-- Select a DOCX Template --"
579611
self.fields["pptx_template"].empty_label = "-- Select a PPTX Template --"
580612
self.fields["include_bloodhound_data"].required = False

ghostwriter/reporting/models.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,21 @@ def user_can_view(self, user) -> bool:
451451
return True
452452
return self.client.user_can_view(user)
453453

454+
def can_apply_to_project(self, project) -> bool:
455+
"""Return whether this template is global or scoped to the project's client."""
456+
return self.client_id is None or self.client_id == project.client_id
457+
458+
def can_apply_to_report(self, report, doc_type=None) -> bool:
459+
"""Return whether this template's client and optional document type match a report."""
460+
doc_type_matches = doc_type is None or (
461+
self.doc_type_id is not None and self.doc_type.doc_type.lower() == doc_type.lower()
462+
)
463+
return doc_type_matches and self.can_apply_to_project(report.project)
464+
465+
def user_can_apply_to_report(self, user, report, doc_type=None) -> bool:
466+
"""Return whether the user can view this template and apply it to the report."""
467+
return self.user_can_view(user) and self.can_apply_to_report(report, doc_type)
468+
454469
def get_effective_evidence_image_alignment(self, report_config):
455470
template_alignment = _text_choice_from_stored_value(
456471
EvidenceImageAlignmentOverride, self.evidence_image_alignment

ghostwriter/reporting/tests/test_forms.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
# Ghostwriter Libraries
88
from ghostwriter.factories import (
9+
ClientFactory,
10+
DocTypeFactory,
911
EvidenceOnFindingFactory,
1012
EvidenceOnReportFactory,
1113
FindingNoteFactory,
@@ -16,6 +18,7 @@
1618
ReportFindingLinkFactory,
1719
ReportObservationLinkFactory,
1820
ReportDocxTemplateFactory,
21+
ReportPptxTemplateFactory,
1922
SeverityFactory,
2023
UserFactory,
2124
)
@@ -104,6 +107,111 @@ def test_invalid_pptx_template(self):
104107
self.assertEqual(len(errors), 1)
105108
self.assertEqual(errors[0].code, "invalid_choice")
106109

110+
def test_client_scoped_templates_are_limited_to_selected_project_client(self):
111+
ProjectAssignmentFactory(operator=self.user, project=self.project)
112+
same_client_docx = ReportDocxTemplateFactory(client=self.project.client)
113+
same_client_pptx = ReportPptxTemplateFactory(client=self.project.client)
114+
foreign_docx = ReportDocxTemplateFactory(client=ClientFactory())
115+
foreign_pptx = ReportPptxTemplateFactory(client=ClientFactory())
116+
117+
form = self.form_data(
118+
user=self.user,
119+
title="Scoped Template Report",
120+
archived=False,
121+
project_id=self.project.pk,
122+
docx_template_id=same_client_docx.pk,
123+
pptx_template_id=same_client_pptx.pk,
124+
delivered=False,
125+
)
126+
127+
self.assertTrue(form.is_valid(), form.errors)
128+
self.assertIn(same_client_docx, form.fields["docx_template"].queryset)
129+
self.assertIn(same_client_pptx, form.fields["pptx_template"].queryset)
130+
self.assertNotIn(foreign_docx, form.fields["docx_template"].queryset)
131+
self.assertNotIn(foreign_pptx, form.fields["pptx_template"].queryset)
132+
133+
def test_client_scoped_template_for_other_client_is_invalid(self):
134+
ProjectAssignmentFactory(operator=self.user, project=self.project)
135+
foreign_docx = ReportDocxTemplateFactory(client=ClientFactory())
136+
foreign_pptx = ReportPptxTemplateFactory(client=ClientFactory())
137+
138+
form = self.form_data(
139+
user=self.user,
140+
title="Foreign Template Report",
141+
archived=False,
142+
project_id=self.project.pk,
143+
docx_template_id=foreign_docx.pk,
144+
pptx_template_id=foreign_pptx.pk,
145+
delivered=False,
146+
)
147+
148+
self.assertFalse(form.is_valid())
149+
self.assertEqual(form["docx_template"].errors.as_data()[0].code, "invalid_choice")
150+
self.assertEqual(form["pptx_template"].errors.as_data()[0].code, "invalid_choice")
151+
152+
def test_disabled_project_field_ignores_submitted_project_for_template_choices(self):
153+
ProjectAssignmentFactory(operator=self.user, project=self.project)
154+
other_project = ProjectFactory()
155+
other_docx = ReportDocxTemplateFactory(client=other_project.client)
156+
other_pptx = ReportPptxTemplateFactory(client=other_project.client)
157+
158+
form = ReportForm(
159+
user=self.user,
160+
project=self.project,
161+
instance=self.report,
162+
data={
163+
"title": "Crafted Report Update",
164+
"archived": False,
165+
"project": other_project.pk,
166+
"docx_template": other_docx.pk,
167+
"pptx_template": other_pptx.pk,
168+
"delivered": False,
169+
},
170+
)
171+
172+
self.assertTrue(form.fields["project"].disabled)
173+
self.assertFalse(form.is_valid())
174+
self.assertEqual(form["docx_template"].errors.as_data()[0].code, "invalid_choice")
175+
self.assertEqual(form["pptx_template"].errors.as_data()[0].code, "invalid_choice")
176+
177+
def test_template_choices_ignore_inaccessible_submitted_project(self):
178+
ProjectAssignmentFactory(operator=self.user, project=self.project)
179+
inaccessible_project = ProjectFactory()
180+
inaccessible_docx = ReportDocxTemplateFactory(client=inaccessible_project.client)
181+
inaccessible_pptx = ReportPptxTemplateFactory(client=inaccessible_project.client)
182+
183+
form = self.form_data(
184+
user=self.user,
185+
title="Inaccessible Project Template Report",
186+
archived=False,
187+
project_id=inaccessible_project.pk,
188+
docx_template_id=inaccessible_docx.pk,
189+
pptx_template_id=inaccessible_pptx.pk,
190+
delivered=False,
191+
)
192+
193+
self.assertFalse(form.fields["project"].disabled)
194+
self.assertNotIn(inaccessible_docx, form.fields["docx_template"].queryset)
195+
self.assertNotIn(inaccessible_pptx, form.fields["pptx_template"].queryset)
196+
self.assertFalse(form.is_valid())
197+
self.assertEqual(form["project"].errors.as_data()[0].code, "invalid_choice")
198+
self.assertEqual(form["docx_template"].errors.as_data()[0].code, "invalid_choice")
199+
self.assertEqual(form["pptx_template"].errors.as_data()[0].code, "invalid_choice")
200+
201+
def test_template_choices_handle_non_integer_submitted_project(self):
202+
form = self.form_data(
203+
user=self.user,
204+
title="Invalid Project Template Report",
205+
archived=False,
206+
project_id="not-a-project-id",
207+
docx_template_id=self.report.docx_template.pk,
208+
pptx_template_id=self.report.pptx_template.pk,
209+
delivered=False,
210+
)
211+
212+
self.assertFalse(form.is_valid())
213+
self.assertEqual(form["project"].errors.as_data()[0].code, "invalid_choice")
214+
107215

108216
class ReportObservationLinkUpdateFormTests(TestCase):
109217
"""Collection of tests for :form:`reporting.ReportObservationLinkForm`."""
@@ -486,6 +594,48 @@ def test_mismatch_pptx_template(self):
486594
self.assertEqual(len(errors), 1)
487595
self.assertEqual(errors[0].code, "invalid_choice")
488596

597+
def test_client_scoped_template_for_other_client_is_invalid(self):
598+
foreign_docx = ReportDocxTemplateFactory(client=ClientFactory())
599+
foreign_pptx = ReportPptxTemplateFactory(client=ClientFactory())
600+
601+
form = self.form_data(
602+
instance=self.report,
603+
docx_template=foreign_docx.pk,
604+
pptx_template=foreign_pptx.pk,
605+
)
606+
607+
self.assertFalse(form.is_valid())
608+
self.assertEqual(form["docx_template"].errors.as_data()[0].code, "invalid_choice")
609+
self.assertEqual(form["pptx_template"].errors.as_data()[0].code, "invalid_choice")
610+
611+
def test_client_scoped_template_for_report_client_is_valid(self):
612+
docx_template = ReportDocxTemplateFactory(client=self.report.project.client)
613+
pptx_template = ReportPptxTemplateFactory(client=self.report.project.client)
614+
615+
form = self.form_data(
616+
instance=self.report,
617+
docx_template=docx_template.pk,
618+
pptx_template=pptx_template.pk,
619+
)
620+
621+
self.assertTrue(form.is_valid(), form.errors)
622+
623+
def test_mixed_case_document_type_templates_are_valid(self):
624+
docx_type = DocTypeFactory(doc_type="DoCx", extension="docx", name="DoCx")
625+
pptx_type = DocTypeFactory(doc_type="PpTx", extension="pptx", name="PpTx")
626+
docx_template = ReportDocxTemplateFactory(doc_type=docx_type)
627+
pptx_template = ReportPptxTemplateFactory(doc_type=pptx_type)
628+
629+
form = self.form_data(
630+
instance=self.report,
631+
docx_template=docx_template.pk,
632+
pptx_template=pptx_template.pk,
633+
)
634+
635+
self.assertIn(docx_template, form.fields["docx_template"].queryset)
636+
self.assertIn(pptx_template, form.fields["pptx_template"].queryset)
637+
self.assertTrue(form.is_valid(), form.errors)
638+
489639

490640
class SeverityFormTests(TestCase):
491641
"""Collection of tests for :form:`reporting.SeverityForm`."""

0 commit comments

Comments
 (0)