From 0c03a6d7079e5ad1183f878e4ccaeb030c528ec3 Mon Sep 17 00:00:00 2001 From: Lucas Parzianello Date: Tue, 24 Feb 2026 12:51:27 -0500 Subject: [PATCH 01/11] gwy: renamed file_list to capture_list for consistency --- gateway/sds_gateway/api_methods/tasks.py | 2 +- .../js/deprecated/userSearchComponent.js | 2 +- .../templates/emails/item_download_error.html | 2 +- .../emails/item_download_error_sdk.html | 4 ++-- .../emails/item_download_error_sdk.txt | 2 +- .../templates/users/file_detail.html | 2 +- .../users/temporary_zip_download.html | 2 +- gateway/sds_gateway/users/urls.py | 23 +++++++++++++++++-- 8 files changed, 29 insertions(+), 10 deletions(-) diff --git a/gateway/sds_gateway/api_methods/tasks.py b/gateway/sds_gateway/api_methods/tasks.py index db7521636..e4aed2651 100644 --- a/gateway/sds_gateway/api_methods/tasks.py +++ b/gateway/sds_gateway/api_methods/tasks.py @@ -573,7 +573,7 @@ def notify_shared_users( if item_type == "dataset": item_url = f"{settings.SITE_URL}/users/dataset-list/" elif item_type == "capture": - item_url = f"{settings.SITE_URL}/users/file-list/" + item_url = f"{settings.SITE_URL}/users/capture-list/" else: item_url = settings.SITE_URL diff --git a/gateway/sds_gateway/static/js/deprecated/userSearchComponent.js b/gateway/sds_gateway/static/js/deprecated/userSearchComponent.js index cc3215b2d..3832439ad 100644 --- a/gateway/sds_gateway/static/js/deprecated/userSearchComponent.js +++ b/gateway/sds_gateway/static/js/deprecated/userSearchComponent.js @@ -1095,7 +1095,7 @@ class UserSearchHandler { if (this.itemType === "dataset") { refreshUrl = `/users/dataset-list/?page=${currentPage}&sort_by=${sortBy}&sort_order=${sortOrder}`; } else if (this.itemType === "capture") { - refreshUrl = `/users/file-list/?page=${currentPage}&sort_by=${sortBy}&sort_order=${sortOrder}`; + refreshUrl = `/users/capture-list/?page=${currentPage}&sort_by=${sortBy}&sort_order=${sortOrder}`; } else { console.error(`Unknown item type: ${this.itemType}`); return; diff --git a/gateway/sds_gateway/templates/emails/item_download_error.html b/gateway/sds_gateway/templates/emails/item_download_error.html index e8de56d75..160504ac5 100644 --- a/gateway/sds_gateway/templates/emails/item_download_error.html +++ b/gateway/sds_gateway/templates/emails/item_download_error.html @@ -78,7 +78,7 @@

Request Informa @@ -110,7 +110,7 @@

Request Informa

- Try Again diff --git a/gateway/sds_gateway/templates/emails/item_download_error_sdk.html b/gateway/sds_gateway/templates/emails/item_download_error_sdk.html index 80fd6910c..f6e2693bd 100644 --- a/gateway/sds_gateway/templates/emails/item_download_error_sdk.html +++ b/gateway/sds_gateway/templates/emails/item_download_error_sdk.html @@ -68,7 +68,7 @@

📥 Install th

📖 SDK Download Instructions

- → View SDK instructions on the dataset listing page + → View SDK instructions on the dataset listing page

- View Files diff --git a/gateway/sds_gateway/templates/emails/item_download_error_sdk.txt b/gateway/sds_gateway/templates/emails/item_download_error_sdk.txt index 32c907f09..7bd1652b2 100644 --- a/gateway/sds_gateway/templates/emails/item_download_error_sdk.txt +++ b/gateway/sds_gateway/templates/emails/item_download_error_sdk.txt @@ -15,7 +15,7 @@ Or visit: https://pypi.org/project/spectrumx/ SDK DOWNLOAD INSTRUCTIONS: -Visit: {{ site_url }}/users/{% if item_type == 'dataset' %}dataset-list{% else %}file-list{% endif %}/ +Visit: {{ site_url }}/users/{% if item_type == 'dataset' %}dataset-list{% else %}capture-list{% endif %}/ REQUEST INFORMATION: {{ item_type|title }}: {{ item_name }} diff --git a/gateway/sds_gateway/templates/users/file_detail.html b/gateway/sds_gateway/templates/users/file_detail.html index 32c06deb4..117272410 100644 --- a/gateway/sds_gateway/templates/users/file_detail.html +++ b/gateway/sds_gateway/templates/users/file_detail.html @@ -8,7 +8,7 @@ {% endblock title %} {% block content %}
- Back to File List

File {{ file.name }} diff --git a/gateway/sds_gateway/templates/users/temporary_zip_download.html b/gateway/sds_gateway/templates/users/temporary_zip_download.html index 4f769536e..2f5226cb5 100644 --- a/gateway/sds_gateway/templates/users/temporary_zip_download.html +++ b/gateway/sds_gateway/templates/users/temporary_zip_download.html @@ -116,7 +116,7 @@

Download Not Available

redirectUrl = "{% url 'users:dataset_list' %}"; alertKey = "datasetDownloadAlert"; } else if (filename.startsWith("capture_")) { - redirectUrl = "{% url 'users:file_list' %}"; + redirectUrl = "{% url 'users:capture_list' %}"; alertKey = "captureDownloadAlert"; } else { // Default to dataset list if we can't determine the type diff --git a/gateway/sds_gateway/users/urls.py b/gateway/sds_gateway/users/urls.py index 8a45d3a03..a9e638ba9 100644 --- a/gateway/sds_gateway/users/urls.py +++ b/gateway/sds_gateway/users/urls.py @@ -1,4 +1,5 @@ from django.urls import path +from django.views.generic import RedirectView from .api.views import GetAPIKeyView from .views import CheckFileExistsView @@ -37,8 +38,26 @@ path("view-api-key/", user_api_key_view, name="view_api_key"), path("new-api-key/", new_api_key_view, name="new_api_key"), path("files/", FilesView.as_view(), name="files"), - path("file-list/", ListCapturesView.as_view(), name="file_list"), - path("file-list/api/", user_captures_api_view, name="captures_api"), + path("capture-list/", ListCapturesView.as_view(), name="capture_list"), + path("capture-list/api/", user_captures_api_view, name="capture_list_api"), + path( + "file-list/", + RedirectView.as_view( + pattern_name="users:capture_list", + permanent=True, + query_string=True, + ), + name="file_list_legacy", + ), + path( + "file-list/api/", + RedirectView.as_view( + pattern_name="users:capture_list_api", + permanent=True, + query_string=True, + ), + name="file_list_api_legacy", + ), path("file-detail//", user_file_detail_view, name="file_detail"), path( "files//download/", FileDownloadView.as_view(), name="file_download" From 3da6325b98396ae6e05dcfeccd6759b88ea99c16 Mon Sep 17 00:00:00 2001 From: Lucas Parzianello Date: Tue, 24 Feb 2026 12:51:55 -0500 Subject: [PATCH 02/11] gwy: ui: added icons to navbar entries --- gateway/sds_gateway/templates/base.html | 31 +++++++++++++++++-------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/gateway/sds_gateway/templates/base.html b/gateway/sds_gateway/templates/base.html index efa63a094..2e14c130a 100644 --- a/gateway/sds_gateway/templates/base.html +++ b/gateway/sds_gateway/templates/base.html @@ -100,27 +100,38 @@ diff --git a/gateway/sds_gateway/templates/users/file_list.html b/gateway/sds_gateway/templates/users/file_list.html index f203afd1c..4a5884a96 100644 --- a/gateway/sds_gateway/templates/users/file_list.html +++ b/gateway/sds_gateway/templates/users/file_list.html @@ -118,6 +118,7 @@

Captures

+
diff --git a/gateway/sds_gateway/templates/users/files.html b/gateway/sds_gateway/templates/users/files.html index 12b9d6fc2..d22ce3430 100644 --- a/gateway/sds_gateway/templates/users/files.html +++ b/gateway/sds_gateway/templates/users/files.html @@ -35,183 +35,188 @@
-
- -
-
From 86bc88497a4a415ae98203f7c32074fc67cf0306 Mon Sep 17 00:00:00 2001 From: Lucas Parzianello Date: Tue, 24 Feb 2026 14:13:28 -0500 Subject: [PATCH 07/11] gwy: resolved sfds-314; removed auth requirement; added security checks for templates --- gateway/sds_gateway/users/tests/test_views.py | 275 ++++++++++++++++++ gateway/sds_gateway/users/views.py | 77 ++++- 2 files changed, 337 insertions(+), 15 deletions(-) diff --git a/gateway/sds_gateway/users/tests/test_views.py b/gateway/sds_gateway/users/tests/test_views.py index ec9a73fc9..07c3cb406 100644 --- a/gateway/sds_gateway/users/tests/test_views.py +++ b/gateway/sds_gateway/users/tests/test_views.py @@ -4,6 +4,7 @@ import uuid from http import HTTPStatus from typing import TYPE_CHECKING +from typing import cast import pytest from django.conf import settings @@ -680,3 +681,277 @@ def test_private_dataset_denied_unauthenticated( response = client.get(url, {"dataset_uuid": str(dataset.uuid)}) assert response.status_code == HTTPStatus.NOT_FOUND + + def test_draft_public_dataset_denied_unauthenticated( + self, client: Client, owner: User + ) -> None: + """Draft datasets cannot be public, but test that draft is denied.""" + dataset = DatasetFactory( + owner=owner, + status=DatasetStatus.DRAFT, + is_public=False, + ) + + url = reverse("users:dataset_details") + response = client.get(url, {"dataset_uuid": str(dataset.uuid)}) + + assert response.status_code == HTTPStatus.NOT_FOUND + + def test_private_dataset_accessible_to_owner( + self, client: Client, owner: User + ) -> None: + """Owner can access their private datasets.""" + dataset = DatasetFactory( + owner=owner, + status=DatasetStatus.FINAL, + is_public=False, + ) + + client.force_login(owner) + url = reverse("users:dataset_details") + response = client.get(url, {"dataset_uuid": str(dataset.uuid)}) + + assert response.status_code == HTTPStatus.OK + payload = response.json() + assert payload["dataset"]["uuid"] == str(dataset.uuid) + + +class TestRenderHTMLFragmentView: + """Tests for RenderHTMLFragmentView - security and access control.""" + + @pytest.fixture + def client(self) -> Client: + return Client() + + @pytest.fixture + def user(self) -> User: + return cast("User", UserFactory(is_approved=True)) + + def test_render_fragment_without_authentication(self, client: Client) -> None: + """Unauthenticated users can render HTML fragments.""" + url = reverse("users:render_html") + data = { + "template": "users/components/modal_file_tree.html", + "context": {"rows": []}, + } + + response = client.post( + url, + data=json.dumps(data), + content_type="application/json", + ) + + assert response.status_code == HTTPStatus.OK + payload = response.json() + assert "html" in payload + + def test_render_fragment_with_authentication( + self, client: Client, user: User + ) -> None: + """Authenticated users can also render HTML fragments.""" + client.force_login(user) + url = reverse("users:render_html") + data = { + "template": "users/components/modal_file_tree.html", + "context": {"rows": []}, + } + + response = client.post( + url, + data=json.dumps(data), + content_type="application/json", + ) + + assert response.status_code == HTTPStatus.OK + payload = response.json() + assert "html" in payload + + def test_rejects_templates_outside_components_directory( + self, client: Client + ) -> None: + """Only templates in users/components/ are allowed.""" + url = reverse("users:render_html") + + # Try various path traversal attempts + malicious_paths = [ + "users/user_detail.html", # Outside components/ + "../base.html", # Path traversal + "../../config/settings/base.py", # Try to access Python files + "/etc/passwd", # Absolute path + "users/components/../user_detail.html", # Path normalization attack + ] + + for template_path in malicious_paths: + data = { + "template": template_path, + "context": {}, + } + + response = client.post( + url, + data=json.dumps(data), + content_type="application/json", + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST, ( + f"Template {template_path} should be rejected" + ) + payload = response.json() + assert "error" in payload + + def test_requires_valid_json(self, client: Client) -> None: + """Request must contain valid JSON.""" + url = reverse("users:render_html") + + response = client.post( + url, + data="invalid json{", + content_type="application/json", + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + payload = response.json() + assert "error" in payload + assert "JSON" in payload["error"] + + def test_requires_template_parameter(self, client: Client) -> None: + """Template parameter is required.""" + url = reverse("users:render_html") + data = { + "context": {"rows": []}, + # Missing "template" key + } + + response = client.post( + url, + data=json.dumps(data), + content_type="application/json", + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + payload = response.json() + assert "error" in payload + assert "required" in payload["error"].lower() + + def test_handles_nonexistent_template_gracefully(self, client: Client) -> None: + """Non-existent templates return 500 error.""" + url = reverse("users:render_html") + data = { + "template": "users/components/nonexistent_template.html", + "context": {}, + } + + response = client.post( + url, + data=json.dumps(data), + content_type="application/json", + ) + + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + payload = response.json() + assert "error" in payload + + def test_renders_with_empty_context(self, client: Client) -> None: + """Templates can be rendered with empty context.""" + url = reverse("users:render_html") + data = { + "template": "users/components/modal_file_tree.html", + "context": {}, + } + + response = client.post( + url, + data=json.dumps(data), + content_type="application/json", + ) + + assert response.status_code == HTTPStatus.OK + payload = response.json() + assert "html" in payload + + def test_context_data_is_properly_escaped(self, client: Client) -> None: + """Context data with HTML/JS is properly escaped.""" + url = reverse("users:render_html") + + # Attempt XSS through context data + malicious_data = "" + data = { + "template": "users/components/modal_file_tree.html", + "context": { + "rows": [ + { + "name": malicious_data, + "type": "File", + "size": "1 MB", + "created_at": "2024-01-01", + "icon": "bi-file", + "icon_color": "text-primary", + "indent_level": 0, + "indent_range": [], + "has_chevron": False, + } + ] + }, + } + + response = client.post( + url, + data=json.dumps(data), + content_type="application/json", + ) + + assert response.status_code == HTTPStatus.OK + payload = response.json() + html = payload["html"] + + # Verify HTML is escaped (Django's default behavior) + assert "<script>" in html or malicious_data not in html + # Make sure raw script tag is NOT present + assert "