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
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/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/commandcenter/templates/user_extra_fields/field.html b/ghostwriter/commandcenter/templates/user_extra_fields/field.html
index a53e438d8..17d0cc87e 100644
--- a/ghostwriter/commandcenter/templates/user_extra_fields/field.html
+++ b/ghostwriter/commandcenter/templates/user_extra_fields/field.html
@@ -10,12 +10,19 @@
{% elif field_spec.type == "rich_text" %}
{{ field_value|rich_text_preview:preview_report }}
{% elif field_spec.type == "json" %}
-
-
+
+ {% 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 %}
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/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_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..cbc6ac155 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 %}
@@ -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 70354e678..8a78d84e2 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
@@ -212,6 +213,31 @@ 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(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):
report_config = ReportConfiguration.get_solo()
report_config.enable_borders = True
@@ -1000,6 +1026,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")
@@ -1031,6 +1060,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):
@@ -1397,6 +1430,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):
@@ -1442,6 +1477,59 @@ 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.assertEqual(response.status_code, 200)
+ self.assertIsNotNone(match)
+ autocomplete = json.loads(match.group(1))
+ self.assertTrue(
+ any(payload in finding["value"] for finding in autocomplete["findings"])
+ )
+ 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)
+ payload = "quote');window.evidenceFilenameXss=true;('.txt"
+ Evidence.objects.filter(pk=evidence.pk).update(
+ document=f"evidence/{self.report.pk}/{payload}"
+ )
+
+ response = self.client_mgr.get(self.uri)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(
+ response,
+ r"quote\u0027)\u003Bwindow.evidenceFilenameXss\u003Dtrue\u003B(\u0027.txt",
+ )
+ self.assertNotContains(response, payload)
+
def test_view_without_findings_does_not_initialize_severity_sortables(self):
response = self.client_mgr.get(self.uri)
@@ -3473,6 +3561,34 @@ 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"
+ self.ReportTemplate.objects.filter(pk=template.pk).update(document=payload)
+
+ response = self.client_mgr.get(self.uri)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(
+ response,
+ r"quote\u0027)\u003Bwindow.templateFilenameXss\u003Dtrue\u003B(\u0027.docx",
+ )
+ self.assertNotContains(response, payload)
+
def test_template_filtering(self):
response = self.client_auth.get(self.uri)
self.assertEqual(response.status_code, 200)
@@ -5378,6 +5494,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")
@@ -5389,6 +5510,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_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/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_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/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 faac567e3..1259ab441 100644
--- a/ghostwriter/rolodex/tests/test_views.py
+++ b/ghostwriter/rolodex/tests/test_views.py
@@ -35,6 +35,7 @@
ProjectAssignmentFactory,
ProjectObjectiveFactory,
ProjectScopeFactory,
+ ProjectSubtaskFactory,
ServerHistoryFactory,
StaticServerFactory,
TransientServerFactory,
@@ -775,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):
@@ -977,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)
@@ -1153,6 +1162,22 @@ def test_view_uri_exists_at_desired_location(self):
response = self.client_mgr.get(self.uri)
self.assertEqual(response.status_code, 200)
+ def test_calendar_escapes_user_controlled_titles_for_javascript(self):
+ payload = "'+(function(){window.calendarXss=true})()+'"
+ self.user.name = payload
+ self.user.save()
+ ProjectAssignmentFactory(project=self.project, operator=self.user)
+ objective = ProjectObjectiveFactory(project=self.project, objective=payload, deadline=date.today())
+ ProjectSubtaskFactory(parent=objective, task=payload, deadline=date.today())
+
+ response = self.client_mgr.get(self.uri)
+ content = force_str(response.content)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertNotIn(f"title: '{payload}'", content)
+ self.assertIn(r"\u0027", content)
+ self.assertIn(r"\u003C/script\u003E", content)
+
def test_context_data_scopes_collab_jwt_to_project(self):
response = self.client_mgr.get(self.uri)
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/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",
+ ),
+ ),
+ ]
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/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..51bf3a367 100644
--- a/ghostwriter/shepherd/templates/shepherd/domain_list.html
+++ b/ghostwriter/shepherd/templates/shepherd/domain_list.html
@@ -124,6 +124,8 @@
{% block morescripts %}
+ {{ autocomplete|json_script:"domain-autocomplete-data" }}
+ {{ tag_autocomplete_data|json_script:"domain-tag-autocomplete-data" }}
{% comment %} jQuery Tablesorter Script {% endcomment %}
"
+ category_payload = "'+(function(){window.domainCategoryXss=true})()+'"
+ domain = DomainFactory._meta.model.objects.first()
+ DomainFactory._meta.model.objects.filter(pk=domain.pk).update(
+ name=name_payload,
+ categorization={"source": category_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(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_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)
@@ -811,6 +849,37 @@ 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_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/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",
+)
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,
}
)
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 '';
}
diff --git a/ghostwriter/templates/account/login.html b/ghostwriter/templates/account/login.html
index 5dfdb220f..d2b019047 100644
--- a/ghostwriter/templates/account/login.html
+++ b/ghostwriter/templates/account/login.html
@@ -44,11 +44,11 @@
{% trans "Sign In" %}
{% 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..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 %}
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 @@
{% trans 'Confirm' %}
-{% 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 %}
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: