Skip to content
Merged
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security

* 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
* Additional inline JavaScript values and activity-log rich-text previews are escaped or sanitized for their output context
* Added matching Django and Hasura validation for domain and static server names while preserving user access to create and manage shared inventory

## [7.2.4] - 21 July 2026

### Added
Expand Down
28 changes: 28 additions & 0 deletions ghostwriter/api/tests/test_hasura_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,34 @@ def test_collab_role_has_no_actions_or_mutations(self):
class HasuraMetadataUserRoleTests(SimpleTestCase):
"""Validate user-role Hasura metadata for app-level RBAC contracts."""

def test_inventory_mutations_validate_user_controlled_names(self):
expected_checks = {
"public_shepherd_domain.yaml": {
"name": {
"_regex": (
r"^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+"
r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.?$"
)
}
},
"public_shepherd_staticserver.yaml": {
"name": {
"_regex": r"^(?:[A-Za-z0-9](?:[A-Za-z0-9._-]{0,253}[A-Za-z0-9])?)?$"
}
},
}

for filename, expected_check in expected_checks.items():
table = load_yaml(HASURA_TABLE_DIR / filename)
for role in ("manager", "user"):
for permission_type in ("insert_permissions", "update_permissions"):
permission = get_role_permission(table, role, permission_type)
self.assertEqual(
permission["permission"]["check"],
expected_check,
f"{filename}: {role} {permission_type}",
)

def test_reporting_library_graphql_names_match_editor_contract(self):
expected_names = {
"public_reporting_finding.yaml": "finding",
Expand Down
11 changes: 11 additions & 0 deletions ghostwriter/commandcenter/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,17 @@ class BannerConfiguration(SingletonModel):
def __str__(self):
return "Banner Settings"

@property
def safe_banner_link(self):
"""Return the banner link only when it is a valid HTTP(S) URL."""
if not self.banner_link:
return ""
try:
URLValidator(schemes=["http", "https"])(self.banner_link)
except ValidationError:
return ""
return self.banner_link

class Meta:
verbose_name = "Banner Configuration"

Expand Down
19 changes: 13 additions & 6 deletions ghostwriter/commandcenter/templates/user_extra_fields/field.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,19 @@
{% elif field_spec.type == "rich_text" %}
<div class="rich-text-field-preview">{{ field_value|rich_text_preview:preview_report }}</div>
{% elif field_spec.type == "json" %}
<pre><code id="jsonPreview"></code></pre>
<script>
$('#jsonPreview').jsonView({
'{{ field_spec.display_name }}': {{ field_value|json_pretty|safe }}
})
</script>
<pre><code></code></pre>
{% with data_element_id="extra-field-json-"|add:field_spec.internal_name %}
{{ field_value|json_script:data_element_id }}
<script>
(function (script) {
const dataElement = script.previousElementSibling;
const previewElement = dataElement.previousElementSibling.querySelector('code');
$(previewElement).jsonView({
'{{ field_spec.display_name|escapejs }}': JSON.parse(dataElement.textContent)
});
})(document.currentScript);
</script>
{% endwith %}
{% else %}
{% if field_value is None %}No Value Set{% else %}{{ field_value }}{% endif %}
{% endif %}
Expand Down
12 changes: 12 additions & 0 deletions ghostwriter/commandcenter/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,18 @@ def test_get_solo_method(self):
except Exception:
self.fail("BannerConfiguration model `get_solo` method failed unexpectedly!")

def test_safe_banner_link_allows_only_http_and_https(self):
entry = self.BannerConfiguration.get_solo()

entry.banner_link = "https://ghostwriter.wiki/security?source=banner&view=full"
self.assertEqual(entry.safe_banner_link, entry.banner_link)

entry.banner_link = "javascript:alert(document.domain)"
self.assertEqual(entry.safe_banner_link, "")

entry.banner_link = 'https://example.com/\" onclick=\"alert(1)'
self.assertEqual(entry.safe_banner_link, "")


class ExtraFieldSpecModelTests(TestCase):
"""Collection of tests for :model:`commandcenter.ExtraFieldSpec`."""
Expand Down
19 changes: 4 additions & 15 deletions ghostwriter/reporting/templates/reporting/finding_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@

{% block morescripts %}
<script type="application/javascript" src="{% static 'js/table-columns.js' %}"></script>
{{ autocomplete_data|json_script:"finding-autocomplete-data" }}

{% comment %} jQuery Tablesorter Script {% endcomment %}
<script>
Expand Down Expand Up @@ -257,24 +258,12 @@

{% comment %} Enable autocomplete for the filter form {% endcomment %}
$(function () {
let availableTitles = [
{% for entry in autocomplete %}
'{{ entry.title|escapejs }}',
{% endfor %}
];
const autocompleteData = JSON.parse(document.getElementById('finding-autocomplete-data').textContent);
$('#id_title').autocomplete({
source: availableTitles
source: autocompleteData.titles
});
});

$(function () {
let availableTags = [
{% for tag in tags %}
'{{ tag.name|escapejs }}',
{% endfor %}
];
$('#id_tags').autocomplete({
source: availableTags
source: autocompleteData.tags
});
});

Expand Down
19 changes: 4 additions & 15 deletions ghostwriter/reporting/templates/reporting/observation_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@


{% block morescripts %}
{{ autocomplete_data|json_script:"observation-autocomplete-data" }}
{% comment %} jQuery Tablesorter Script {% endcomment %}
<script>
$(document).ready(function () {
Expand Down Expand Up @@ -130,24 +131,12 @@

{% comment %} Enable autocomplete for the filter form {% endcomment %}
$(function () {
let availableTitles = [
{% for entry in autocomplete %}
'{{ entry.title|escapejs }}',
{% endfor %}
];
const autocompleteData = JSON.parse(document.getElementById('observation-autocomplete-data').textContent);
$('#id_title').autocomplete({
source: availableTitles
source: autocompleteData.titles
});
});

$(function () {
let availableTags = [
{% for tag in tags %}
'{{ tag.name|escapejs }}',
{% endfor %}
];
$('#id_tags').autocomplete({
source: availableTags
source: autocompleteData.tags
});
});

Expand Down
32 changes: 6 additions & 26 deletions ghostwriter/reporting/templates/reporting/report_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ <h4>Report Evidence</h4>
onclick="openModal('#evidence_detail_{{ evidence_file.id }}')"
></span>
<a class="icon download-icon" href="javascript:void(0)"
onclick="downloadEvidence({{ evidence_file.id }}, '{{ evidence_file.filename }}')" title="Download this evidence file"></a>
onclick="downloadEvidence({{ evidence_file.id }}, '{{ evidence_file.filename|escapejs }}')" title="Download this evidence file"></a>
<a href="{% url 'reporting:evidence_update' evidence_file.id %}"
title="Edit this evidence file"
class="icon edit-icon"></a>
Expand Down Expand Up @@ -443,8 +443,10 @@ <h3><i class="fas fa-cog fa-spin"></i> Generating your report...</h3>
</div>
</div>

{{ autocomplete_data|json_script:"report-detail-autocomplete-data" }}
<script>
const $spinnerModal = $('#spinner-modal')
const reportAutocompleteData = JSON.parse(document.getElementById('report-detail-autocomplete-data').textContent);

{% comment %} Set variables used in `report_findings_table.html` that is updated on changes {% endcomment %}
let report_id = '{{ report.id }}';
Expand Down Expand Up @@ -666,26 +668,15 @@ <h3><i class="fas fa-cog fa-spin"></i> Generating your report...</h3>

{% comment %} Enable autocomplete for the findings and observations search {% endcomment %}
$(function () {
let availableFindingTitles = [
{% for entry in finding_autocomplete %}
{
value: '{{ entry.severity }} : {{ entry.title|escapejs }}{% for tag in entry.tags.all %}{% if forloop.first %} ({% endif %}{{ tag.name|escapejs }}{% if forloop.last %}){% else %}, {% endif %}{% endfor %}',
id: '{{ entry.id }}',
url: '{% url 'reporting:ajax_assign_finding' entry.id %}',
csrftoken: '{{ csrf_token }}'
},
{% endfor %}
];
$("#id_finding_search").autocomplete({
source: availableFindingTitles,
source: reportAutocompleteData.findings,
minLength: 0,
open: function () {
$("ul.ui-menu").width($(this).innerWidth());
},
select: function (event, ui) {
let url = ui.item.url
let findingId = ui.item.id
let csrftoken = ui.item.csrftoken
// Prep AJAX request with CSRF token
$.ajaxSetup({
beforeSend: function (xhr, settings) {
Expand Down Expand Up @@ -721,26 +712,15 @@ <h3><i class="fas fa-cog fa-spin"></i> Generating your report...</h3>
});

$(function () {
let availableObservationTitles = [
{% for entry in observation_autocomplete %}
{
value: '{{ entry.title|escapejs }}{% for tag in entry.tags.all %}{% if forloop.first %} ({% endif %}{{ tag.name|escapejs }}{% if forloop.last %}){% else %}, {% endif %}{% endfor %}',
id: '{{ entry.id }}',
url: '{% url 'reporting:ajax_assign_observation' entry.id %}',
csrftoken: '{{ csrf_token }}'
},
{% endfor %}
];
$("#id_observation_search").autocomplete({
source: availableObservationTitles,
source: reportAutocompleteData.observations,
minLength: 0,
open: function () {
$("ul.ui-menu").width($(this).innerWidth());
},
select: function (event, ui) {
let url = ui.item.url
let observationId = ui.item.id
let csrftoken = ui.item.csrftoken
// Prep AJAX request with CSRF token
$.ajaxSetup({
beforeSend: function (xhr, settings) {
Expand Down Expand Up @@ -788,7 +768,7 @@ <h3><i class="fas fa-cog fa-spin"></i> Generating your report...</h3>
let evidenceFiles = [
{
text: '\{\{.caption\}\}',
value: '<p class="evidence-caption">{{ report_config.label_figure }} # {{ report_config.prefix_figure }} '
value: '<p class="evidence-caption">{{ report_config.label_figure|force_escape|escapejs }} # {{ report_config.prefix_figure|force_escape|escapejs }} '
},
{% if report.evidence_set.all %}
{% for evidence in report.evidence_set.all %}
Expand Down
9 changes: 3 additions & 6 deletions ghostwriter/reporting/templates/reporting/report_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -114,15 +114,12 @@
});
</script>

{{ autocomplete_data|json_script:"report-list-autocomplete-data" }}
<script>
$(function () {
let availableTags = [
{% for tag in tags %}
'{{ tag.name|escapejs }}',
{% endfor %}
];
const autocompleteData = JSON.parse(document.getElementById('report-list-autocomplete-data').textContent);
$('#id_tags').autocomplete({
source: availableTags
source: autocompleteData.tags
});
});
</script>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@
<script>
{% for field in form %}
{% for error in field.errors %}
displayToastTop({type: 'error', string: '{{ error }}', context: 'form'});
displayToastTop({type: 'error', string: '{{ error|escapejs }}', context: 'form'});
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
displayToastTop({type: 'error', string: '{{ error }}', context: 'form'});
displayToastTop({type: 'error', string: '{{ error|escapejs }}', context: 'form'});
{% endfor %}
</script>
{% endif %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
onclick="openModal('#template_detail_{{ template.id }}')"
></span>
<a class="icon download-icon" href="javascript:void(0)"
onclick="downloadTemplate({{ template.id }}, '{{ template.filename }}')" title="Download template"></a>
onclick="downloadTemplate({{ template.id }}, '{{ template.filename|escapejs }}')" title="Download template"></a>
{% if template.protected and request.user|is_privileged or not template.protected %}
<a class="icon edit-icon"
href="{% url 'reporting:template_update' template.id %}"></a>
Expand Down Expand Up @@ -175,15 +175,12 @@
}
</script>

{{ autocomplete_data|json_script:"report-template-autocomplete-data" }}
<script>
$(function () {
let availableTags = [
{% for tag in tags %}
'{{ tag.name|escapejs }}',
{% endfor %}
];
const autocompleteData = JSON.parse(document.getElementById('report-template-autocomplete-data').textContent);
$('#id_tags').autocomplete({
source: availableTags
source: autocompleteData.tags
});
});
</script>
Expand Down
Loading
Loading