From e7f6e9b4d8427b90c10f548f9030ac81c36f318e Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Wed, 22 Jul 2026 10:59:07 -0700 Subject: [PATCH 01/11] Expanded use of escapejs over bleach --- .../templates/user_extra_fields/field.html | 13 +++++++--- .../templates/reporting/report_detail.html | 6 ++--- .../reporting/report_template_form.html | 4 +-- .../reporting/report_templates_list.html | 2 +- .../templates/rolodex/client_detail.html | 4 +-- .../templates/rolodex/client_form.html | 4 +-- .../templates/rolodex/project_detail.html | 25 ++++++++++--------- .../templates/rolodex/project_form.html | 4 +-- .../shepherd/templates/shepherd/checkout.html | 4 +-- .../templates/shepherd/domain_list.html | 7 ++---- .../templates/shepherd/server_list.html | 7 ++---- ghostwriter/templates/account/login.html | 4 +-- .../templates/account/reauthenticate.html | 4 +-- ghostwriter/templates/base_generic.html | 2 +- ghostwriter/templates/base_generic_empty.html | 10 ++++---- ghostwriter/templates/index.html | 2 +- ghostwriter/templates/mfa/authenticate.html | 4 +-- ghostwriter/templates/mfa/reauthenticate.html | 6 ++--- .../templates/mfa/totp/activate_form.html | 4 +-- .../templates/mfa/totp/deactivate_form.html | 4 +-- .../templates/mfa/webauthn/add_form.html | 4 +-- .../users/templates/users/profile_form.html | 4 +-- 22 files changed, 64 insertions(+), 64 deletions(-) diff --git a/ghostwriter/commandcenter/templates/user_extra_fields/field.html b/ghostwriter/commandcenter/templates/user_extra_fields/field.html index a53e438d8..eb9cb0644 100644 --- a/ghostwriter/commandcenter/templates/user_extra_fields/field.html +++ b/ghostwriter/commandcenter/templates/user_extra_fields/field.html @@ -10,11 +10,16 @@ {% elif field_spec.type == "rich_text" %}
{{ field_value|rich_text_preview:preview_report }}
{% elif field_spec.type == "json" %} -
+
+ {{ field_value|json_script }} {% else %} {% if field_value is None %}No Value Set{% else %}{{ field_value }}{% endif %} diff --git a/ghostwriter/reporting/templates/reporting/report_detail.html b/ghostwriter/reporting/templates/reporting/report_detail.html index c195b5eff..73f150328 100644 --- a/ghostwriter/reporting/templates/reporting/report_detail.html +++ b/ghostwriter/reporting/templates/reporting/report_detail.html @@ -346,7 +346,7 @@

Report Evidence

onclick="openModal('#evidence_detail_{{ evidence_file.id }}')" > + onclick="downloadEvidence({{ evidence_file.id }}, '{{ evidence_file.filename|escapejs }}')" title="Download this evidence file"> @@ -669,7 +669,7 @@

Generating your report...

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 %}', + value: '{{ entry.severity|escapejs }} : {{ 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 }}' @@ -788,7 +788,7 @@

Generating your report...

let evidenceFiles = [ { text: '\{\{.caption\}\}', - value: '

{{ report_config.label_figure }} # {{ report_config.prefix_figure }} ' + value: '

{{ 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 %} diff --git a/ghostwriter/reporting/templates/reporting/report_template_form.html b/ghostwriter/reporting/templates/reporting/report_template_form.html index a12b09c19..0f3571f3a 100644 --- a/ghostwriter/reporting/templates/reporting/report_template_form.html +++ b/ghostwriter/reporting/templates/reporting/report_template_form.html @@ -23,11 +23,11 @@ {% endif %} diff --git a/ghostwriter/reporting/templates/reporting/report_templates_list.html b/ghostwriter/reporting/templates/reporting/report_templates_list.html index f49f31d03..5c3e5980c 100644 --- a/ghostwriter/reporting/templates/reporting/report_templates_list.html +++ b/ghostwriter/reporting/templates/reporting/report_templates_list.html @@ -94,7 +94,7 @@ onclick="openModal('#template_detail_{{ template.id }}')" > + onclick="downloadTemplate({{ template.id }}, '{{ template.filename|escapejs }}')" title="Download template"> {% if template.protected and request.user|is_privileged or not template.protected %} diff --git a/ghostwriter/rolodex/templates/rolodex/client_detail.html b/ghostwriter/rolodex/templates/rolodex/client_detail.html index 5eb0c410f..a7b5da7e6 100644 --- a/ghostwriter/rolodex/templates/rolodex/client_detail.html +++ b/ghostwriter/rolodex/templates/rolodex/client_detail.html @@ -551,8 +551,8 @@

Client Notes

{% endif %} diff --git a/ghostwriter/rolodex/templates/rolodex/project_detail.html b/ghostwriter/rolodex/templates/rolodex/project_detail.html index 22fb40d76..eeb09166a 100644 --- a/ghostwriter/rolodex/templates/rolodex/project_detail.html +++ b/ghostwriter/rolodex/templates/rolodex/project_detail.html @@ -1365,7 +1365,8 @@

Project Legacy Comments

let update_url = $objTable.attr('objectives-update-url'); let project_id = $objTable.attr('project-id'); let csrftoken = $objTable.attr('objectives-update-csrftoken'); - let {{ group | lower }}_sortable = Sortable.create({{ group | lower }}_priority, { + const priority_{{ forloop.counter0 }}_element = document.getElementById('{{ group|lower|escapejs }}_priority'); + let priority_{{ forloop.counter0 }}_sortable = Sortable.create(priority_{{ forloop.counter0 }}_element, { items: 'tbody > tr', group: 'priority', animation: 150, @@ -1430,7 +1431,7 @@

Project Legacy Comments

{% comment %} Element is dropped into the list from another list {% endcomment %} onAdd: function (event) { let placeholder = event.to.getElementsByClassName('priority-row-placeholder')[0]; - let positions = {{ group | lower }}_sortable.toArray(); + let positions = priority_{{ forloop.counter0 }}_sortable.toArray(); $.ajaxSetup({ beforeSend: function (xhr, settings) { if (!csrfSafeMethod(settings.type) && !this.crossDomain) { @@ -1469,7 +1470,7 @@

Project Legacy Comments

{% comment %} Changed sorting within list {% endcomment %} onUpdate: function (event) { - let positions = {{ group | lower }}_sortable.toArray(); + let positions = priority_{{ forloop.counter0 }}_sortable.toArray(); $.ajaxSetup({ beforeSend: function (xhr, settings) { if (!csrfSafeMethod(settings.type) && !this.crossDomain) { @@ -1493,7 +1494,7 @@

Project Legacy Comments

{% comment %} Element is removed from the list into another list {% endcomment %} onRemove: function (event) { let placeholder = event.from.getElementsByClassName('priority-row-placeholder')[0]; - let positions = {{ group | lower }}_sortable.toArray(); + let positions = priority_{{ forloop.counter0 }}_sortable.toArray(); $.ajaxSetup({ beforeSend: function (xhr, settings) { if (!csrfSafeMethod(settings.type) && !this.crossDomain) { @@ -1519,12 +1520,12 @@

Project Legacy Comments

{% endcomment %} if (event.from.rows.length === 1) { let placeholder_row = event.from.insertRow(0) - placeholder_row.id = '{{ group|lower }}_placeholder' + placeholder_row.id = '{{ group|lower|escapejs }}_placeholder' placeholder_row.className = 'priority-row-placeholder' - placeholder_row.setAttribute('data-id', '{{ group|lower }}_placeholder') + placeholder_row.setAttribute('data-id', '{{ group|lower|escapejs }}_placeholder') let cell = placeholder_row.insertCell(0); cell.colSpan = 7 - cell.innerHTML = 'Create a {{ group }} objective or drag-and-drop an objective here to update its priority.' + cell.textContent = 'Create a {{ group|escapejs }} objective or drag-and-drop an objective here to update its priority.' } else { if (placeholder !== undefined) { placeholder.parentNode.parentNode.deleteRow(placeholder.rowIndex) @@ -1551,7 +1552,7 @@

Project Legacy Comments

}, }); } catch (error) { - console.log('No objectives found for the "{{ group | lower }}" group, so the sortable list is not initialized.'); + console.log('No objectives found for the "{{ group|lower|escapejs }}" group, so the sortable list is not initialized.'); } {% endfor %} @@ -1749,8 +1750,8 @@

Project Legacy Comments

{% comment %} Add project assignments {% endcomment %} {% for operator in project.projectassignment_set.all %} let operator_{{ operator.id }} = { - title: {% if operator.operator.name %}'{{ operator.operator.name|bleach }}' - {% else %}'{{ operator.operator.username|bleach }}'{% endif %}, + title: {% if operator.operator.name %}'{{ operator.operator.name|escapejs }}' + {% else %}'{{ operator.operator.username|escapejs }}'{% endif %}, allDay: true, startRecur: '{{ operator.start_date|date:"Y-m-d" }}', endRecur: '{{ operator.end_date|plus_days:1|date:"Y-m-d" }}', @@ -1764,7 +1765,7 @@

Project Legacy Comments

{% comment %} Add project objectives {% endcomment %} {% for objective in project.projectobjective_set.all %} let obj_{{ objective.id }} = { - title: '{{ objective.objective|striptags }}', + title: '{{ objective.objective|striptags|escapejs }}', allDay: true, start: '{{ objective.deadline|date:"Y-m-d" }}', end: '{{ objective.deadline|date:"Y-m-d" }}', @@ -1777,7 +1778,7 @@

Project Legacy Comments

{% if objective.projectsubtask_set.all %} {% for task in objective.projectsubtask_set.all %} let task_{{ task.id }} = { - title: '{{ task.task|striptags }}', + title: '{{ task.task|striptags|escapejs }}', allDay: true, start: '{{ task.deadline|date:"Y-m-d" }}', end: '{{ task.deadline|date:"Y-m-d" }}', diff --git a/ghostwriter/rolodex/templates/rolodex/project_form.html b/ghostwriter/rolodex/templates/rolodex/project_form.html index 9d5005878..35a633c05 100644 --- a/ghostwriter/rolodex/templates/rolodex/project_form.html +++ b/ghostwriter/rolodex/templates/rolodex/project_form.html @@ -39,11 +39,11 @@ {% endif %} diff --git a/ghostwriter/shepherd/templates/shepherd/checkout.html b/ghostwriter/shepherd/templates/shepherd/checkout.html index 5f4de95e5..cc514d65a 100644 --- a/ghostwriter/shepherd/templates/shepherd/checkout.html +++ b/ghostwriter/shepherd/templates/shepherd/checkout.html @@ -23,11 +23,11 @@ {% endif %} diff --git a/ghostwriter/shepherd/templates/shepherd/domain_list.html b/ghostwriter/shepherd/templates/shepherd/domain_list.html index 61c76859a..a9f4a3f80 100644 --- a/ghostwriter/shepherd/templates/shepherd/domain_list.html +++ b/ghostwriter/shepherd/templates/shepherd/domain_list.html @@ -124,6 +124,7 @@ {% block morescripts %} + {{ autocomplete|json_script:"domain-autocomplete-data" }} {% comment %} jQuery Tablesorter Script {% endcomment %} {% endif %} diff --git a/ghostwriter/templates/account/reauthenticate.html b/ghostwriter/templates/account/reauthenticate.html index 435bb6584..f04740e49 100644 --- a/ghostwriter/templates/account/reauthenticate.html +++ b/ghostwriter/templates/account/reauthenticate.html @@ -14,11 +14,11 @@ {% endif %} diff --git a/ghostwriter/templates/base_generic.html b/ghostwriter/templates/base_generic.html index fc988c0ae..3cf59c999 100644 --- a/ghostwriter/templates/base_generic.html +++ b/ghostwriter/templates/base_generic.html @@ -469,7 +469,7 @@ {% if banner_config.banner_title %}

{{ banner_config.banner_title|bleach }}

{% endif %} {{ banner_config.banner_message|bleach }} {% if banner_config.banner_link %} -

Learn more

+

Learn more

{% endif %} diff --git a/ghostwriter/templates/base_generic_empty.html b/ghostwriter/templates/base_generic_empty.html index 2c895af02..af876f823 100644 --- a/ghostwriter/templates/base_generic_empty.html +++ b/ghostwriter/templates/base_generic_empty.html @@ -245,15 +245,15 @@ {% for message in messages %} {% if 'no-toast' not in message.tags %} {% if message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} - displayToastTop({type: 'success', string: '{{ message }}'}); + displayToastTop({type: 'success', string: '{{ message|escapejs }}'}); {% elif message.level == DEFAULT_MESSAGE_LEVELS.WARNING %} - displayToastTop({type: 'warning', string: '{{ message }}'}); + displayToastTop({type: 'warning', string: '{{ message|escapejs }}'}); {% elif message.level == DEFAULT_MESSAGE_LEVELS.ERROR %} - displayToastTop({type: 'error', string: '{{ message }}'}); + displayToastTop({type: 'error', string: '{{ message|escapejs }}'}); {% elif message.level == DEFAULT_MESSAGE_LEVELS.INFO %} - displayToastTop({type: 'info', string: '{{ message }}'}); + displayToastTop({type: 'info', string: '{{ message|escapejs }}'}); {% elif message.level == DEFAULT_MESSAGE_LEVELS.DEBUG %} - displayToastTop({type: 'info', string: '{{ message }}'}); + displayToastTop({type: 'info', string: '{{ message|escapejs }}'}); {% endif %} {% endif %} {% endfor %} diff --git a/ghostwriter/templates/index.html b/ghostwriter/templates/index.html index 0f05972d3..3c61b4ec8 100644 --- a/ghostwriter/templates/index.html +++ b/ghostwriter/templates/index.html @@ -239,7 +239,7 @@ }, body: JSON.stringify({ 'hide_quickstart': true, - 'username': '{{ user.username }}' + 'username': '{{ user.username|escapejs }}' }) }) .then(response => { diff --git a/ghostwriter/templates/mfa/authenticate.html b/ghostwriter/templates/mfa/authenticate.html index 735d37e0b..a2253e35a 100644 --- a/ghostwriter/templates/mfa/authenticate.html +++ b/ghostwriter/templates/mfa/authenticate.html @@ -12,12 +12,12 @@

{% endif %} diff --git a/ghostwriter/templates/mfa/reauthenticate.html b/ghostwriter/templates/mfa/reauthenticate.html index 61dd1bca2..c11687256 100644 --- a/ghostwriter/templates/mfa/reauthenticate.html +++ b/ghostwriter/templates/mfa/reauthenticate.html @@ -14,11 +14,11 @@ {% endif %} @@ -31,4 +31,4 @@ -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/ghostwriter/templates/mfa/totp/activate_form.html b/ghostwriter/templates/mfa/totp/activate_form.html index a69347551..de8e66e7f 100644 --- a/ghostwriter/templates/mfa/totp/activate_form.html +++ b/ghostwriter/templates/mfa/totp/activate_form.html @@ -45,11 +45,11 @@

{% endif %} diff --git a/ghostwriter/templates/mfa/totp/deactivate_form.html b/ghostwriter/templates/mfa/totp/deactivate_form.html index 284162c67..d9bfcbe12 100644 --- a/ghostwriter/templates/mfa/totp/deactivate_form.html +++ b/ghostwriter/templates/mfa/totp/deactivate_form.html @@ -29,11 +29,11 @@

{% endif %} diff --git a/ghostwriter/templates/mfa/webauthn/add_form.html b/ghostwriter/templates/mfa/webauthn/add_form.html index 3cfa67bf9..a8ca7e22a 100644 --- a/ghostwriter/templates/mfa/webauthn/add_form.html +++ b/ghostwriter/templates/mfa/webauthn/add_form.html @@ -26,11 +26,11 @@

{% endif %} diff --git a/ghostwriter/users/templates/users/profile_form.html b/ghostwriter/users/templates/users/profile_form.html index b9920cf24..ba061c41f 100644 --- a/ghostwriter/users/templates/users/profile_form.html +++ b/ghostwriter/users/templates/users/profile_form.html @@ -22,11 +22,11 @@ {% endif %} From c81c9b2178e91db9ebe0f4a6f56ec7921933c037 Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Wed, 22 Jul 2026 11:00:20 -0700 Subject: [PATCH 02/11] Added validation to domain and server names --- ghostwriter/shepherd/models.py | 15 ++++++++++++++- ghostwriter/shepherd/validators.py | 27 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 ghostwriter/shepherd/validators.py diff --git a/ghostwriter/shepherd/models.py b/ghostwriter/shepherd/models.py index 1b40e40fc..2ecd8f435 100644 --- a/ghostwriter/shepherd/models.py +++ b/ghostwriter/shepherd/models.py @@ -16,6 +16,12 @@ # 3rd Party Libraries from taggit.managers import TaggableManager +# Ghostwriter Libraries +from ghostwriter.shepherd.validators import ( + validate_inventory_domain_name, + validate_inventory_server_name, +) + class HealthStatus(models.Model): """ @@ -132,7 +138,13 @@ class Domain(models.Model): and :model:`users.User`. """ - name = models.CharField("Name", max_length=255, unique=True, help_text="Enter the domain name") + name = models.CharField( + "Name", + max_length=255, + unique=True, + validators=[validate_inventory_domain_name], + help_text="Enter the domain name", + ) registrar = models.CharField( "Registrar", max_length=255, @@ -433,6 +445,7 @@ class StaticServer(models.Model): max_length=255, default="", blank=True, + validators=[validate_inventory_server_name], help_text="Enter the server's name (typically hostname)", ) tags = TaggableManager(blank=True) diff --git a/ghostwriter/shepherd/validators.py b/ghostwriter/shepherd/validators.py new file mode 100644 index 000000000..a37b8f770 --- /dev/null +++ b/ghostwriter/shepherd/validators.py @@ -0,0 +1,27 @@ +"""Validators for user-controlled inventory identifiers.""" + +# Django Imports +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ + +DOMAIN_NAME_PATTERN = ( + r"\A(?:[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])?\.?\Z" +) +SERVER_NAME_PATTERN = r"\A(?:[A-Za-z0-9](?:[A-Za-z0-9._-]{0,253}[A-Za-z0-9])?)?\Z" + +validate_inventory_domain_name = RegexValidator( + regex=DOMAIN_NAME_PATTERN, + message=_( + "Enter a valid domain name using letters, numbers, periods, and hyphens." + ), + code="invalid_domain_name", +) + +validate_inventory_server_name = RegexValidator( + regex=SERVER_NAME_PATTERN, + message=_( + "Enter a valid server name using letters, numbers, periods, underscores, and hyphens." + ), + code="invalid_server_name", +) From 9f2522b9a2637f89ce7f94cad31fc09f49648fa4 Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Wed, 22 Jul 2026 11:01:05 -0700 Subject: [PATCH 03/11] Added regex checks to GraphQL mutations --- .../default/tables/public_shepherd_domain.yaml | 16 ++++++++++++---- .../tables/public_shepherd_staticserver.yaml | 16 ++++++++++++---- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/hasura-docker/metadata/databases/default/tables/public_shepherd_domain.yaml b/hasura-docker/metadata/databases/default/tables/public_shepherd_domain.yaml index 473be96b5..8c08990f7 100644 --- a/hasura-docker/metadata/databases/default/tables/public_shepherd_domain.yaml +++ b/hasura-docker/metadata/databases/default/tables/public_shepherd_domain.yaml @@ -73,7 +73,9 @@ array_relationships: insert_permissions: - role: manager permission: - check: {} + check: + name: + _regex: '^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.?$' columns: - auto_renew - categorization @@ -92,7 +94,9 @@ insert_permissions: - whois_status_id - role: user permission: - check: {} + check: + name: + _regex: '^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.?$' columns: - auto_renew - burned_explanation @@ -167,7 +171,9 @@ update_permissions: - vt_permalink - whois_status_id filter: {} - check: {} + check: + name: + _regex: '^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.?$' - role: user permission: columns: @@ -188,7 +194,9 @@ update_permissions: - vt_permalink - whois_status_id filter: {} - check: {} + check: + name: + _regex: '^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.?$' event_triggers: - name: CleanDomainName definition: diff --git a/hasura-docker/metadata/databases/default/tables/public_shepherd_staticserver.yaml b/hasura-docker/metadata/databases/default/tables/public_shepherd_staticserver.yaml index 1b073cc3f..25bbd5e4e 100644 --- a/hasura-docker/metadata/databases/default/tables/public_shepherd_staticserver.yaml +++ b/hasura-docker/metadata/databases/default/tables/public_shepherd_staticserver.yaml @@ -65,7 +65,9 @@ array_relationships: insert_permissions: - role: manager permission: - check: {} + check: + name: + _regex: '^(?:[A-Za-z0-9](?:[A-Za-z0-9._-]{0,253}[A-Za-z0-9])?)?$' columns: - extra_fields - ip_address @@ -75,7 +77,9 @@ insert_permissions: - server_status_id - role: user permission: - check: {} + check: + name: + _regex: '^(?:[A-Za-z0-9](?:[A-Za-z0-9._-]{0,253}[A-Za-z0-9])?)?$' columns: - extra_fields - ip_address @@ -118,7 +122,9 @@ update_permissions: - server_provider_id - server_status_id filter: {} - check: {} + check: + name: + _regex: '^(?:[A-Za-z0-9](?:[A-Za-z0-9._-]{0,253}[A-Za-z0-9])?)?$' - role: user permission: columns: @@ -129,7 +135,9 @@ update_permissions: - server_provider_id - server_status_id filter: {} - check: {} + check: + name: + _regex: '^(?:[A-Za-z0-9](?:[A-Za-z0-9._-]{0,253}[A-Za-z0-9])?)?$' delete_permissions: - role: manager permission: From 46e277d6fc01a0a26fac6107e33c035fef741243 Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Wed, 22 Jul 2026 11:01:28 -0700 Subject: [PATCH 04/11] Improved validation for oplog --- ghostwriter/static/js/oplog.js | 6 +++++- ghostwriter/static/js/project.js | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/ghostwriter/static/js/oplog.js b/ghostwriter/static/js/oplog.js index 80b2c63c6..7410aeb15 100644 --- a/ghostwriter/static/js/oplog.js +++ b/ghostwriter/static/js/oplog.js @@ -273,7 +273,11 @@ $(document).ready(function () { if (spec.type === 'checkbox') { toHtmlFunc = v => (v ? '' : ''); } else if (spec.type === 'rich_text') { - toHtmlFunc = v => v; + toHtmlFunc = v => { + if (!v) return ''; + let safe = (typeof DOMPurify !== 'undefined') ? DOMPurify.sanitize(v) : jsEscape(v); + return jsEscape(truncateText($('
').html(safe).text(), 100)); + }; } else { toHtmlFunc = jsEscape; } diff --git a/ghostwriter/static/js/project.js b/ghostwriter/static/js/project.js index 148ccd17a..2637e93bb 100644 --- a/ghostwriter/static/js/project.js +++ b/ghostwriter/static/js/project.js @@ -127,7 +127,12 @@ function update_badges() { // Escape HTML characters in a string to make it safe for display function jsEscape(s) { if (s) { - return s.toString().replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } else { return ''; } From b44652eade4d2d3660f17da1f2eaf86917e0afd5 Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Wed, 22 Jul 2026 11:01:41 -0700 Subject: [PATCH 05/11] Migration to add validators to domain and server names --- ...ter_domain_name_alter_staticserver_name.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 ghostwriter/shepherd/migrations/0053_alter_domain_name_alter_staticserver_name.py diff --git a/ghostwriter/shepherd/migrations/0053_alter_domain_name_alter_staticserver_name.py b/ghostwriter/shepherd/migrations/0053_alter_domain_name_alter_staticserver_name.py new file mode 100644 index 000000000..183a37197 --- /dev/null +++ b/ghostwriter/shepherd/migrations/0053_alter_domain_name_alter_staticserver_name.py @@ -0,0 +1,49 @@ +# Generated by Django 5.2.14 on 2026-07-22 17:21 + +# Django Imports +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("shepherd", "0052_rename_note_to_description"), + ] + + operations = [ + migrations.AlterField( + model_name="domain", + name="name", + field=models.CharField( + help_text="Enter the domain name", + max_length=255, + unique=True, + validators=[ + django.core.validators.RegexValidator( + code="invalid_domain_name", + message="Enter a valid domain name using letters, numbers, periods, and hyphens.", + regex="\\A(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\\.)+[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\\.?\\Z", + ) + ], + verbose_name="Name", + ), + ), + migrations.AlterField( + model_name="staticserver", + name="name", + field=models.CharField( + blank=True, + default="", + help_text="Enter the server's name (typically hostname)", + max_length=255, + validators=[ + django.core.validators.RegexValidator( + code="invalid_server_name", + message="Enter a valid server name using letters, numbers, periods, underscores, and hyphens.", + regex="\\A(?:[A-Za-z0-9](?:[A-Za-z0-9._-]{0,253}[A-Za-z0-9])?)?\\Z", + ) + ], + verbose_name="Name", + ), + ), + ] From acc48f16cae4752047cf6d7bf97208e37a4b6cff Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Wed, 22 Jul 2026 11:01:50 -0700 Subject: [PATCH 06/11] Updated tests --- ghostwriter/api/tests/test_hasura_metadata.py | 28 +++++++ ghostwriter/reporting/tests/test_views.py | 83 +++++++++++++++++++ ghostwriter/rolodex/tests/test_views.py | 17 ++++ ghostwriter/shepherd/tests/test_forms.py | 18 ++++ ghostwriter/shepherd/tests/test_views.py | 49 ++++++++++- 5 files changed, 193 insertions(+), 2 deletions(-) diff --git a/ghostwriter/api/tests/test_hasura_metadata.py b/ghostwriter/api/tests/test_hasura_metadata.py index 0111d1f2a..84961f9c2 100644 --- a/ghostwriter/api/tests/test_hasura_metadata.py +++ b/ghostwriter/api/tests/test_hasura_metadata.py @@ -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", diff --git a/ghostwriter/reporting/tests/test_views.py b/ghostwriter/reporting/tests/test_views.py index 70354e678..676481b25 100644 --- a/ghostwriter/reporting/tests/test_views.py +++ b/ghostwriter/reporting/tests/test_views.py @@ -212,6 +212,30 @@ def test_field_spec_filters(self): field_spec = ExtraFieldSpec.objects.filter(target_model="reporting.Report") self.assertTrue(report_tags.has_non_rt_fields(field_spec)) + def test_json_extra_field_is_serialized_as_inert_data(self): + field_spec = ExtraFieldSpecFactory( + internal_name="structured_data", + display_name="Structured Data", + type="json", + target_model=ExtraFieldModelFactory( + model_internal_name="reporting.Report", + model_display_name="Reports", + ), + ) + payload = "" + + rendered = render_to_string( + "user_extra_fields/field.html", + { + "extra_fields": {"structured_data": {"value": payload}}, + "field_spec": field_spec, + }, + ) + + self.assertNotIn(payload, rendered) + self.assertIn(r"\u003C/script\u003E", rendered) + self.assertIn("JSON.parse(dataElement.textContent)", rendered) + def test_rich_text_extra_field_renders_report_evidence_previews(self): report_config = ReportConfiguration.get_solo() report_config.enable_borders = True @@ -1442,6 +1466,51 @@ def test_view_uses_correct_template(self): self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, "reporting/report_detail.html") + def test_report_caption_configuration_is_safe_in_javascript_html(self): + report_config = ReportConfiguration.get_solo() + ReportConfiguration.objects.filter(pk=report_config.pk).update( + label_figure="Figure\u2028window.captionXss=true//", + prefix_figure="

", + ) + + 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"\u0026lt\u003B/p\u0026gt\u003B") + self.assertNotContains(response, "

(.*?)', + content, + re.DOTALL, + ) + + self.assertIsNotNone(match) + autocomplete = json.loads(match.group(1)) + self.assertIn(name_payload, autocomplete) + self.assertIn(category_payload.lower(), autocomplete) + self.assertNotIn("", match.group(1)) + self.assertIn(r"\u003C/script\u003E", match.group(1)) + def test_domain_filtering(self): # Filter defaults to only showing available domains (id 1), so we should only see 3 response = self.client_auth.get(self.uri) @@ -811,6 +837,25 @@ def test_custom_context_exists(self): self.assertIn("filter", response.context) self.assertIn("autocomplete", response.context) + def test_autocomplete_serializes_user_controlled_values_as_json(self): + payload = "'+(function(){window.serverNameXss=true})()+'" + server = StaticServerFactory._meta.model.objects.first() + StaticServerFactory._meta.model.objects.filter(pk=server.pk).update(name=payload) + + response = self.client_auth.get(self.uri) + content = force_str(response.content) + match = re.search( + r'', + content, + re.DOTALL, + ) + + self.assertIsNotNone(match) + autocomplete = json.loads(match.group(1)) + self.assertIn(payload, autocomplete) + self.assertNotIn("", match.group(1)) + self.assertIn(r"\u003C/script\u003E", match.group(1)) + def test_server_filtering(self): # Filter defaults to only showing available servers (id 1), so we should only see 2 response = self.client_auth.get(self.uri) From fa3c807b5f1f00bf191a8fb6761a5c9eeec5e13b Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Wed, 22 Jul 2026 11:15:43 -0700 Subject: [PATCH 07/11] Autocomplete updates to use `json_script` --- .../templates/reporting/finding_list.html | 19 ++---- .../templates/reporting/observation_list.html | 19 ++---- .../templates/reporting/report_detail.html | 28 ++------- .../templates/reporting/report_list.html | 9 +-- .../reporting/report_templates_list.html | 9 +-- ghostwriter/reporting/tests/test_views.py | 55 +++++++++++++++-- ghostwriter/reporting/views2/finding.py | 7 ++- ghostwriter/reporting/views2/observations.py | 11 +++- ghostwriter/reporting/views2/report.py | 60 ++++++++++++++++++- .../templates/rolodex/client_list.html | 19 ++---- .../templates/rolodex/project_list.html | 32 ++-------- ghostwriter/rolodex/tests/test_views.py | 8 +++ ghostwriter/rolodex/views.py | 23 ++++++- .../templates/shepherd/domain_list.html | 7 +-- .../templates/shepherd/server_list.html | 7 +-- ghostwriter/shepherd/tests/test_views.py | 24 ++++++++ ghostwriter/shepherd/views.py | 17 ++++-- 17 files changed, 217 insertions(+), 137 deletions(-) diff --git a/ghostwriter/reporting/templates/reporting/finding_list.html b/ghostwriter/reporting/templates/reporting/finding_list.html index 0be3bb150..935b9c3c2 100644 --- a/ghostwriter/reporting/templates/reporting/finding_list.html +++ b/ghostwriter/reporting/templates/reporting/finding_list.html @@ -157,6 +157,7 @@ {% block morescripts %} + {{ autocomplete_data|json_script:"finding-autocomplete-data" }} {% comment %} jQuery Tablesorter Script {% endcomment %} + {{ autocomplete_data|json_script:"report-list-autocomplete-data" }} diff --git a/ghostwriter/reporting/templates/reporting/report_templates_list.html b/ghostwriter/reporting/templates/reporting/report_templates_list.html index 5c3e5980c..cbc6ac155 100644 --- a/ghostwriter/reporting/templates/reporting/report_templates_list.html +++ b/ghostwriter/reporting/templates/reporting/report_templates_list.html @@ -175,15 +175,12 @@ } + {{ autocomplete_data|json_script:"report-template-autocomplete-data" }} diff --git a/ghostwriter/reporting/tests/test_views.py b/ghostwriter/reporting/tests/test_views.py index 676481b25..dedce2367 100644 --- a/ghostwriter/reporting/tests/test_views.py +++ b/ghostwriter/reporting/tests/test_views.py @@ -3,6 +3,7 @@ import json import logging import os +import re import zipfile from datetime import datetime, timedelta, timezone from xml.etree import ElementTree @@ -1024,6 +1025,9 @@ 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"]) def test_search_report_findings(self): response = self.client_auth.get(self.uri + "?on_reports=on") @@ -1055,6 +1059,10 @@ def test_report_finding_tags_are_scoped_to_accessible_report_findings(self): self.assertIn("visible-report-finding-tag", tag_names) self.assertNotIn("hidden-report-finding-tag", tag_names) self.assertNotIn("hidden-master-finding-tag", tag_names) + autocomplete_tags = response.context["autocomplete_data"]["tags"] + self.assertIn("visible-report-finding-tag", autocomplete_tags) + self.assertNotIn("hidden-report-finding-tag", autocomplete_tags) + self.assertNotIn("hidden-master-finding-tag", autocomplete_tags) class FindingDetailViewTests(TestCase): @@ -1421,6 +1429,8 @@ 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"]) class ReportDetailViewTests(TestCase): @@ -1482,18 +1492,26 @@ def test_report_caption_configuration_is_safe_in_javascript_html(self): self.assertContains(response, r"\u0026lt\u003B/p\u0026gt\u003B") self.assertNotContains(response, "

(.*?)', + content, + re.DOTALL, + ) self.assertEqual(response.status_code, 200) - self.assertContains( - response, - r"Critical\u0027+window.severityXss\u003Dtrue+\u0027", + self.assertIsNotNone(match) + autocomplete = json.loads(match.group(1)) + self.assertTrue( + any(payload in finding["value"] for finding in autocomplete["findings"]) ) - self.assertNotContains(response, payload) + self.assertNotIn("", match.group(1)) + self.assertIn(r"\u003C/script\u003E", match.group(1)) def test_evidence_filename_is_escaped_in_inline_javascript(self): evidence = EvidenceFactory(report=self.report) @@ -3542,6 +3560,20 @@ def test_view_uses_correct_template(self): self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, "reporting/report_templates_list.html") + def test_tags_are_scoped_to_visible_templates(self): + self.templates[0].tags.add("visible-template-tag") + + response = self.client_auth.get(self.uri) + + self.assertEqual(response.status_code, 200) + tag_names = list(response.context["tags"].values_list("name", flat=True)) + self.assertIn("visible-template-tag", tag_names) + self.assertNotIn("tag1", tag_names) + self.assertIn( + "visible-template-tag", response.context["autocomplete_data"]["tags"] + ) + self.assertNotIn("tag1", response.context["autocomplete_data"]["tags"]) + def test_template_filename_is_escaped_in_inline_javascript(self): template = self.templates[0] payload = "quote');window.templateFilenameXss=true;('.docx" @@ -5461,6 +5493,11 @@ def test_tags_are_scoped_to_observations(self): self.assertNotIn("hidden-report-tag", tag_names) self.assertNotIn("hidden-project-tag", tag_names) self.assertNotIn("hidden-finding-tag", tag_names) + autocomplete_tags = response.context["autocomplete_data"]["tags"] + self.assertIn("visible-observation-tag", autocomplete_tags) + self.assertNotIn("hidden-report-tag", autocomplete_tags) + self.assertNotIn("hidden-project-tag", autocomplete_tags) + self.assertNotIn("hidden-finding-tag", autocomplete_tags) def test_tags_are_scoped_to_filtered_observation_queryset(self): other_observation = ObservationFactory(title="Other Observation") @@ -5472,6 +5509,12 @@ def test_tags_are_scoped_to_filtered_observation_queryset(self): tag_names = list(response.context["tags"].values_list("name", flat=True)) self.assertIn("visible-observation-tag", tag_names) self.assertNotIn("other-observation-tag", tag_names) + self.assertIn( + "visible-observation-tag", response.context["autocomplete_data"]["tags"] + ) + self.assertNotIn( + "other-observation-tag", response.context["autocomplete_data"]["tags"] + ) class ObservationCreateViewTests(TestCase): diff --git a/ghostwriter/reporting/views2/finding.py b/ghostwriter/reporting/views2/finding.py index 76930be2c..1690ea3df 100644 --- a/ghostwriter/reporting/views2/finding.py +++ b/ghostwriter/reporting/views2/finding.py @@ -75,12 +75,17 @@ def get_queryset(self): def get(self, request, *args, **kwarg): queryset = self.get_queryset() findings_filter = FindingFilter(request.GET, queryset=queryset) + tags = get_tags_for_queryset(queryset) return render( request, "reporting/finding_list.html", { "filter": findings_filter, "autocomplete": self.autocomplete, + "autocomplete_data": { + "titles": list(self.autocomplete.values_list("title", flat=True)), + "tags": list(tags.values_list("name", flat=True)), + }, "searching_report_findings": self.searching_report_findings, - "tags": get_tags_for_queryset(queryset), + "tags": tags, } ) diff --git a/ghostwriter/reporting/views2/observations.py b/ghostwriter/reporting/views2/observations.py index 72754af1f..1468f2595 100644 --- a/ghostwriter/reporting/views2/observations.py +++ b/ghostwriter/reporting/views2/observations.py @@ -52,10 +52,19 @@ def get_queryset(self): def get(self, request: HttpRequest, *args, **kwarg) -> HttpResponse: queryset = self.get_queryset() observation_filter = ObservationFilter(request.GET, queryset=queryset, request=self.request) + tags = get_tags_for_queryset(queryset) return render( request, "reporting/observation_list.html", - {"filter": observation_filter, "autocomplete": self.autocomplete, "tags": get_tags_for_queryset(queryset),}, + { + "filter": observation_filter, + "autocomplete": self.autocomplete, + "autocomplete_data": { + "titles": [observation.title for observation in self.autocomplete], + "tags": list(tags.values_list("name", flat=True)), + }, + "tags": tags, + }, ) diff --git a/ghostwriter/reporting/views2/report.py b/ghostwriter/reporting/views2/report.py index 32624d073..df7fd24f5 100644 --- a/ghostwriter/reporting/views2/report.py +++ b/ghostwriter/reporting/views2/report.py @@ -230,10 +230,17 @@ def get_queryset(self): def get(self, request, *args, **kwarg): queryset = self.get_queryset() reports_filter = ReportFilter(request.GET, queryset=queryset) + tags = get_tags_for_queryset(queryset) return render( request, "reporting/report_list.html", - {"filter": reports_filter, "tags": get_tags_for_queryset(queryset)} + { + "filter": reports_filter, + "autocomplete_data": { + "tags": list(tags.values_list("name", flat=True)) + }, + "tags": tags, + }, ) @@ -361,11 +368,46 @@ def get_context_data(self, **kwargs): for obs in observations: self.observation_autocomplete.append(obs) ctx["observation_autocomplete"] = self.observation_autocomplete + ctx["autocomplete_data"] = { + "findings": [ + { + "value": self._autocomplete_label( + finding.title, + finding.tags.all(), + prefix=f"{finding.severity} : ", + ), + "id": finding.pk, + "url": reverse("reporting:ajax_assign_finding", args=[finding.pk]), + } + for finding in self.finding_autocomplete + ], + "observations": [ + { + "value": self._autocomplete_label( + observation.title, + observation.tags.all(), + ), + "id": observation.pk, + "url": reverse( + "reporting:ajax_assign_observation", + args=[observation.pk], + ), + } + for observation in self.observation_autocomplete + ], + } ctx["report_extra_fields_spec"] = ExtraFieldSpec.objects.filter(target_model=Report._meta.label) ctx["report_config"] = ReportConfiguration.get_solo() return ctx + @staticmethod + def _autocomplete_label(title, tags, prefix=""): + """Build the plain-text label displayed by a report autocomplete.""" + tag_names = [tag.name for tag in tags] + tag_suffix = f" ({', '.join(tag_names)})" if tag_names else "" + return f"{prefix}{title}{tag_suffix}" + class ReportCreate(RoleBasedAccessControlMixin, CreateView): """ @@ -668,8 +710,20 @@ def get_queryset(self): return queryset def get(self, request, *args, **kwarg): - templates_filter = ReportTemplateFilter(request.GET, queryset=self.get_queryset()) - return render(request, "reporting/report_templates_list.html", {"filter": templates_filter}) + queryset = self.get_queryset() + templates_filter = ReportTemplateFilter(request.GET, queryset=queryset) + tags = get_tags_for_queryset(queryset) + return render( + request, + "reporting/report_templates_list.html", + { + "filter": templates_filter, + "autocomplete_data": { + "tags": list(tags.values_list("name", flat=True)) + }, + "tags": tags, + }, + ) class ReportTemplateDetailView(RoleBasedAccessControlMixin, DetailView): diff --git a/ghostwriter/rolodex/templates/rolodex/client_list.html b/ghostwriter/rolodex/templates/rolodex/client_list.html index aad4d2593..0f6f82cec 100644 --- a/ghostwriter/rolodex/templates/rolodex/client_list.html +++ b/ghostwriter/rolodex/templates/rolodex/client_list.html @@ -76,27 +76,16 @@ }); + {{ autocomplete_data|json_script:"client-autocomplete-data" }} {% comment %} Enable autocomplete for the filter form {% endcomment %} diff --git a/ghostwriter/rolodex/templates/rolodex/project_list.html b/ghostwriter/rolodex/templates/rolodex/project_list.html index fe651c11b..9253c03d0 100644 --- a/ghostwriter/rolodex/templates/rolodex/project_list.html +++ b/ghostwriter/rolodex/templates/rolodex/project_list.html @@ -93,41 +93,19 @@ }); + {{ autocomplete_data|json_script:"project-autocomplete-data" }} {% comment %} Enable autocomplete for the filter form {% endcomment %} - - diff --git a/ghostwriter/rolodex/tests/test_views.py b/ghostwriter/rolodex/tests/test_views.py index b57bd41a7..1259ab441 100644 --- a/ghostwriter/rolodex/tests/test_views.py +++ b/ghostwriter/rolodex/tests/test_views.py @@ -776,6 +776,8 @@ def test_tags_are_scoped_to_visible_clients(self): tag_names = list(response.context["tags"].values_list("name", flat=True)) self.assertIn("visible-tag", tag_names) self.assertNotIn("hidden-tag", tag_names) + self.assertIn("visible-tag", response.context["autocomplete_data"]["tags"]) + self.assertNotIn("hidden-tag", response.context["autocomplete_data"]["tags"]) class ClientCreateViewTests(TestCase): @@ -978,6 +980,12 @@ def test_tags_are_scoped_to_visible_projects(self): tag_names = list(response.context["tags"].values_list("name", flat=True)) self.assertIn("visible-project-tag", tag_names) self.assertNotIn("hidden-project-tag", tag_names) + self.assertIn( + "visible-project-tag", response.context["autocomplete_data"]["tags"] + ) + self.assertNotIn( + "hidden-project-tag", response.context["autocomplete_data"]["tags"] + ) response = self.client_mgr.get(f"{self.uri}?client=pops") self.assertEqual(response.status_code, 200) diff --git a/ghostwriter/rolodex/views.py b/ghostwriter/rolodex/views.py index d3c39b83a..b215e4522 100644 --- a/ghostwriter/rolodex/views.py +++ b/ghostwriter/rolodex/views.py @@ -1239,7 +1239,12 @@ def get_context_data(self, **kwargs): queryset = self.get_queryset() ctx["filter"] = ClientFilter(self.request.GET, queryset=queryset, request=self.request) ctx["autocomplete"] = self.autocomplete - ctx["tags"] = get_tags_for_queryset(queryset) + tags = get_tags_for_queryset(queryset) + ctx["tags"] = tags + ctx["autocomplete_data"] = { + "names": list(self.autocomplete.values_list("name", flat=True)), + "tags": list(tags.values_list("name", flat=True)), + } return ctx @@ -1682,7 +1687,21 @@ def get_context_data(self, **kwargs): data["complete"] = 0 ctx["filter"] = ProjectFilter(data, queryset=queryset, request=self.request) ctx["autocomplete"] = self.autocomplete - ctx["tags"] = get_tags_for_queryset(queryset) + tags = get_tags_for_queryset(queryset) + ctx["tags"] = tags + ctx["autocomplete_data"] = { + "clients": list( + self.autocomplete.order_by("client__name") + .values_list("client__name", flat=True) + .distinct() + ), + "codenames": list( + self.autocomplete.order_by("codename") + .values_list("codename", flat=True) + .distinct() + ), + "tags": list(tags.values_list("name", flat=True)), + } return ctx diff --git a/ghostwriter/shepherd/templates/shepherd/domain_list.html b/ghostwriter/shepherd/templates/shepherd/domain_list.html index a9f4a3f80..51bf3a367 100644 --- a/ghostwriter/shepherd/templates/shepherd/domain_list.html +++ b/ghostwriter/shepherd/templates/shepherd/domain_list.html @@ -125,6 +125,7 @@ {% block morescripts %} {{ autocomplete|json_script:"domain-autocomplete-data" }} + {{ tag_autocomplete_data|json_script:"domain-tag-autocomplete-data" }} {% comment %} jQuery Tablesorter Script {% endcomment %} ", match.group(1)) self.assertIn(r"\u003C/script\u003E", match.group(1)) + def test_tag_autocomplete_is_scoped_to_domains(self): + domain = DomainFactory._meta.model.objects.first() + domain.tags.add("domain-only-tag") + unrelated_project = ProjectFactory() + unrelated_project.tags.add("project-only-tag") + + response = self.client_auth.get(self.uri) + + self.assertEqual(response.status_code, 200) + self.assertIn("domain-only-tag", response.context["tag_autocomplete_data"]) + self.assertNotIn("project-only-tag", response.context["tag_autocomplete_data"]) + def test_domain_filtering(self): # Filter defaults to only showing available domains (id 1), so we should only see 3 response = self.client_auth.get(self.uri) @@ -856,6 +868,18 @@ def test_autocomplete_serializes_user_controlled_values_as_json(self): self.assertNotIn("", match.group(1)) self.assertIn(r"\u003C/script\u003E", match.group(1)) + def test_tag_autocomplete_is_scoped_to_servers(self): + server = StaticServerFactory._meta.model.objects.first() + server.tags.add("server-only-tag") + unrelated_project = ProjectFactory() + unrelated_project.tags.add("project-only-tag") + + response = self.client_auth.get(self.uri) + + self.assertEqual(response.status_code, 200) + self.assertIn("server-only-tag", response.context["tag_autocomplete_data"]) + self.assertNotIn("project-only-tag", response.context["tag_autocomplete_data"]) + def test_server_filtering(self): # Filter defaults to only showing available servers (id 1), so we should only see 2 response = self.client_auth.get(self.uri) diff --git a/ghostwriter/shepherd/views.py b/ghostwriter/shepherd/views.py index 4614994f2..72ba6f783 100644 --- a/ghostwriter/shepherd/views.py +++ b/ghostwriter/shepherd/views.py @@ -24,7 +24,6 @@ # 3rd Party Libraries from django_q.models import Task from django_q.tasks import async_task -from taggit.models import Tag # Ghostwriter Libraries from ghostwriter.api.utils import ( @@ -40,7 +39,7 @@ VirusTotalConfiguration, ) from ghostwriter.commandcenter.views import ExtraFieldJsonView -from ghostwriter.modules.shared import add_content_disposition_header +from ghostwriter.modules.shared import add_content_disposition_header, get_tags_for_queryset from ghostwriter.rolodex.models import Client, Project from ghostwriter.shepherd.filters import DomainFilter, ServerFilter from ghostwriter.shepherd.forms import ( @@ -935,14 +934,17 @@ def get(self, request, *args, **kwarg): if len(data) == 0: data["domain_status"] = 1 data["exclude_expired"] = True - domains_filter = DomainFilter(data, queryset=self.get_queryset(), request=self.request) + queryset = self.get_queryset() + domains_filter = DomainFilter(data, queryset=queryset, request=self.request) + tags = get_tags_for_queryset(queryset) return render( request, "shepherd/domain_list.html", { "filter": domains_filter, "autocomplete": self.autocomplete, - "tags": Tag.objects.all(), + "tag_autocomplete_data": list(tags.values_list("name", flat=True)), + "tags": tags, } ) @@ -1012,14 +1014,17 @@ def get(self, request, *args, **kwarg): data = request.GET.copy() if len(data) == 0: data["server_status"] = 1 - servers_filter = ServerFilter(data, queryset=self.get_queryset(), request=self.request) + queryset = self.get_queryset() + servers_filter = ServerFilter(data, queryset=queryset, request=self.request) + tags = get_tags_for_queryset(queryset) return render( request, "shepherd/server_list.html", { "filter": servers_filter, "autocomplete": self.autocomplete, - "tags": Tag.objects.all(), + "tag_autocomplete_data": list(tags.values_list("name", flat=True)), + "tags": tags, } ) From 241c24626f2dc4c89fb84927b6b96888356fd56c Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Wed, 22 Jul 2026 11:52:03 -0700 Subject: [PATCH 08/11] Updated for changes --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 645f63b86..3f8801419 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From ee36b2f685a90cb78c39f33a4c6300bce836fb68 Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Wed, 22 Jul 2026 11:53:14 -0700 Subject: [PATCH 09/11] Adjusted use of `json_script` here to add id value --- .../templates/user_extra_fields/field.html | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/ghostwriter/commandcenter/templates/user_extra_fields/field.html b/ghostwriter/commandcenter/templates/user_extra_fields/field.html index eb9cb0644..17d0cc87e 100644 --- a/ghostwriter/commandcenter/templates/user_extra_fields/field.html +++ b/ghostwriter/commandcenter/templates/user_extra_fields/field.html @@ -11,16 +11,18 @@
{{ field_value|rich_text_preview:preview_report }}
{% elif field_spec.type == "json" %}
- {{ field_value|json_script }} - + {% with data_element_id="extra-field-json-"|add:field_spec.internal_name %} + {{ field_value|json_script:data_element_id }} + + {% endwith %} {% else %} {% if field_value is None %}No Value Set{% else %}{{ field_value }}{% endif %} {% endif %} From 2e5e868c997d711f5e0a78bcd81e465818ec58be Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Wed, 22 Jul 2026 11:54:49 -0700 Subject: [PATCH 10/11] Consistent defense-in-depth sanitization for the banner --- ghostwriter/commandcenter/models.py | 11 +++++++++++ ghostwriter/templates/base_generic.html | 8 +++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/ghostwriter/commandcenter/models.py b/ghostwriter/commandcenter/models.py index d384a7215..356d472a9 100644 --- a/ghostwriter/commandcenter/models.py +++ b/ghostwriter/commandcenter/models.py @@ -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" diff --git a/ghostwriter/templates/base_generic.html b/ghostwriter/templates/base_generic.html index 3cf59c999..ab6f84465 100644 --- a/ghostwriter/templates/base_generic.html +++ b/ghostwriter/templates/base_generic.html @@ -468,9 +468,11 @@ {% if banner_config.banner_title %}

{{ banner_config.banner_title|bleach }}

{% endif %} {{ banner_config.banner_message|bleach }} - {% if banner_config.banner_link %} -

Learn more

- {% endif %} + {% with banner_link=banner_config.safe_banner_link %} + {% if banner_link %} +

Learn more

+ {% endif %} + {% endwith %}
{% endif %} From 75a1b3cb5e64c2b473fcdab68e37832d2952ef83 Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Wed, 22 Jul 2026 11:54:57 -0700 Subject: [PATCH 11/11] Updated tests --- ghostwriter/commandcenter/tests/test_models.py | 12 ++++++++++++ ghostwriter/reporting/tests/test_views.py | 1 + 2 files changed, 13 insertions(+) diff --git a/ghostwriter/commandcenter/tests/test_models.py b/ghostwriter/commandcenter/tests/test_models.py index ac1c9fc80..a93ea8357 100644 --- a/ghostwriter/commandcenter/tests/test_models.py +++ b/ghostwriter/commandcenter/tests/test_models.py @@ -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`.""" diff --git a/ghostwriter/reporting/tests/test_views.py b/ghostwriter/reporting/tests/test_views.py index dedce2367..8a78d84e2 100644 --- a/ghostwriter/reporting/tests/test_views.py +++ b/ghostwriter/reporting/tests/test_views.py @@ -235,6 +235,7 @@ def test_json_extra_field_is_serialized_as_inert_data(self): self.assertNotIn(payload, rendered) self.assertIn(r"\u003C/script\u003E", rendered) + self.assertIn(f'id="extra-field-json-{field_spec.internal_name}"', rendered) self.assertIn("JSON.parse(dataElement.textContent)", rendered) def test_rich_text_extra_field_renders_report_evidence_previews(self):