From a022ad5c7a78a3705f16fb705a5ae599f8e0f779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pe=C3=B1a?= Date: Thu, 16 Jul 2026 11:48:12 +0200 Subject: [PATCH] fix: harden Jira site handling (#12012) --- .../jira-site-name-validation.security.md | 1 + api/src/backend/api/tests/test_serializers.py | 58 +++++++++++- api/src/backend/api/tests/test_utils.py | 10 +-- api/src/backend/api/tests/test_views.py | 88 +++++++++++++++++++ .../api/v1/serializer_utils/integrations.py | 21 ++++- .../jira-tenant-info-request.security.md | 1 + prowler/lib/outputs/jira/jira.py | 8 ++ tests/lib/outputs/jira/jira_test.py | 31 ++++++- 8 files changed, 209 insertions(+), 9 deletions(-) create mode 100644 api/changelog.d/jira-site-name-validation.security.md create mode 100644 prowler/changelog.d/jira-tenant-info-request.security.md diff --git a/api/changelog.d/jira-site-name-validation.security.md b/api/changelog.d/jira-site-name-validation.security.md new file mode 100644 index 0000000000..d06244297a --- /dev/null +++ b/api/changelog.d/jira-site-name-validation.security.md @@ -0,0 +1 @@ +Jira integration credentials only accept bare Atlassian site names containing letters, numbers, and hyphens diff --git a/api/src/backend/api/tests/test_serializers.py b/api/src/backend/api/tests/test_serializers.py index 02559ac91c..0ee674d22c 100644 --- a/api/src/backend/api/tests/test_serializers.py +++ b/api/src/backend/api/tests/test_serializers.py @@ -1,5 +1,8 @@ import pytest -from api.v1.serializer_utils.integrations import S3ConfigSerializer +from api.v1.serializer_utils.integrations import ( + JiraCredentialSerializer, + S3ConfigSerializer, +) from api.v1.serializers import ImageProviderSecret, KubernetesProviderSecret from rest_framework.exceptions import ValidationError @@ -100,6 +103,59 @@ class TestS3ConfigSerializer: assert "output_directory" in serializer.errors +class TestJiraCredentialSerializer: + @pytest.mark.parametrize( + "domain", + ( + "a", + "prowler", + "prowler-domain", + "A1-b2-C3", + "a" * 63, + ), + ) + def test_valid_site_name(self, domain): + serializer = JiraCredentialSerializer( + data={ + "user_mail": "testing@prowler.com", + "api_token": "fake-api-token", + "domain": domain, + } + ) + + assert serializer.is_valid(), serializer.errors + + @pytest.mark.parametrize( + "domain", + ( + "169.254.169.254#", + "internal/service", + "internal?target", + "internal\\target", + "internal:8000", + "user@internal", + "example.atlassian.net", + "-prowler", + "prowler-", + "a" * 64, + " prowler", + "prowler ", + "prowler\n", + ), + ) + def test_invalid_site_name(self, domain): + serializer = JiraCredentialSerializer( + data={ + "user_mail": "testing@prowler.com", + "api_token": "fake-api-token", + "domain": domain, + } + ) + + assert not serializer.is_valid() + assert "domain" in serializer.errors + + class TestImageProviderSecret: """Test cases for ImageProviderSecret validation.""" diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py index 4e7e53cb6f..9e96685477 100644 --- a/api/src/backend/api/tests/test_utils.py +++ b/api/src/backend/api/tests/test_utils.py @@ -856,7 +856,7 @@ class TestProwlerIntegrationConnectionTest: integration.credentials = { "user_mail": "test@example.com", "api_token": "test_api_token", - "domain": "example.atlassian.net", + "domain": "example", } integration.configuration = {} @@ -884,7 +884,7 @@ class TestProwlerIntegrationConnectionTest: mock_jira_class.test_connection.assert_called_once_with( user_mail="test@example.com", api_token="test_api_token", - domain="example.atlassian.net", + domain="example", raise_on_exception=False, ) @@ -917,7 +917,7 @@ class TestProwlerIntegrationConnectionTest: integration.credentials = { "user_mail": "invalid@example.com", "api_token": "invalid_token", - "domain": "invalid.atlassian.net", + "domain": "invalid", } integration.configuration = {} @@ -942,7 +942,7 @@ class TestProwlerIntegrationConnectionTest: mock_jira_class.test_connection.assert_called_once_with( user_mail="invalid@example.com", api_token="invalid_token", - domain="invalid.atlassian.net", + domain="invalid", raise_on_exception=False, ) @@ -970,7 +970,7 @@ class TestProwlerIntegrationConnectionTest: integration.credentials = { "user_mail": "test@example.com", "api_token": "test_api_token", - "domain": "example.atlassian.net", + "domain": "example", } integration.configuration = { "issue_types": {"OLD_PROJ": ["Task"]}, # Existing configuration diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index a1e73b46ec..bfe8e57bc9 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -13423,6 +13423,45 @@ class TestIntegrationViewSet: ) assert "credentials" not in response.json()["data"]["attributes"] + @pytest.mark.parametrize( + "domain", + ( + "169.254.169.254#", + "internal/service", + "internal?target", + "internal\\target", + "internal:8000", + "user@internal", + ), + ) + def test_integrations_create_jira_rejects_invalid_domain( + self, authenticated_client, domain + ): + data = { + "data": { + "type": "integrations", + "attributes": { + "integration_type": Integration.IntegrationChoices.JIRA, + "configuration": {}, + "credentials": { + "domain": domain, + "api_token": "fake-api-token", + "user_mail": "testing@prowler.com", + }, + "enabled": True, + }, + } + } + + response = authenticated_client.post( + reverse("integration-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert Integration.objects.count() == 0 + def test_integrations_create_valid_relationships( self, authenticated_client, @@ -13963,6 +14002,55 @@ class TestIntegrationViewSet: assert "projects" in configuration assert "issue_types" in configuration + def test_integrations_update_jira_rejects_invalid_domain( + self, authenticated_client + ): + create_data = { + "data": { + "type": "integrations", + "attributes": { + "integration_type": Integration.IntegrationChoices.JIRA, + "configuration": {}, + "credentials": { + "user_mail": "test@example.com", + "api_token": "fake-api-token", + "domain": "original-domain", + }, + "enabled": True, + }, + } + } + create_response = authenticated_client.post( + reverse("integration-list"), + data=json.dumps(create_data), + content_type="application/vnd.api+json", + ) + assert create_response.status_code == status.HTTP_201_CREATED + integration_id = create_response.json()["data"]["id"] + + update_data = { + "data": { + "type": "integrations", + "id": integration_id, + "attributes": { + "credentials": { + "user_mail": "test@example.com", + "api_token": "fake-api-token", + "domain": "169.254.169.254#", + } + }, + } + } + response = authenticated_client.patch( + reverse("integration-detail", kwargs={"pk": integration_id}), + data=json.dumps(update_data), + content_type="application/vnd.api+json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + integration = Integration.objects.get(id=integration_id) + assert integration.credentials["domain"] == "original-domain" + @pytest.mark.django_db class TestSAMLTokenValidation: diff --git a/api/src/backend/api/v1/serializer_utils/integrations.py b/api/src/backend/api/v1/serializer_utils/integrations.py index a77de9c237..ac876d1d5b 100644 --- a/api/src/backend/api/v1/serializer_utils/integrations.py +++ b/api/src/backend/api/v1/serializer_utils/integrations.py @@ -5,6 +5,10 @@ from api.v1.serializer_utils.base import BaseValidateSerializer from drf_spectacular.utils import extend_schema_field from rest_framework_json_api import serializers +ATLASSIAN_SITE_NAME_REGEX = re.compile( + r"\A[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\Z" +) + class S3ConfigSerializer(BaseValidateSerializer): bucket_name = serializers.CharField() @@ -97,7 +101,17 @@ class AWSCredentialSerializer(BaseValidateSerializer): class JiraCredentialSerializer(BaseValidateSerializer): user_mail = serializers.EmailField(required=True) api_token = serializers.CharField(required=True) - domain = serializers.CharField(required=True) + domain = serializers.RegexField( + regex=ATLASSIAN_SITE_NAME_REGEX, + required=True, + trim_whitespace=False, + error_messages={ + "invalid": ( + "Domain must be a valid Atlassian site name containing only " + "letters, numbers, and hyphens." + ) + }, + ) class Meta: resource_name = "integrations" @@ -170,7 +184,10 @@ class JiraCredentialSerializer(BaseValidateSerializer): }, "domain": { "type": "string", - "description": "The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').", + "description": "The Jira site name without the '.atlassian.net' suffix (e.g., 'your-domain').", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$", }, }, "required": ["user_mail", "api_token", "domain"], diff --git a/prowler/changelog.d/jira-tenant-info-request.security.md b/prowler/changelog.d/jira-tenant-info-request.security.md new file mode 100644 index 0000000000..270ba01bfc --- /dev/null +++ b/prowler/changelog.d/jira-tenant-info-request.security.md @@ -0,0 +1 @@ +Jira tenant information requests validate site names and do not follow redirects diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index 59b4d015aa..32ee8af4eb 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -1,5 +1,6 @@ import base64 import os +import re from dataclasses import dataclass from datetime import datetime, timedelta from typing import Dict, List, Optional @@ -37,6 +38,10 @@ from prowler.lib.outputs.jira.exceptions.exceptions import ( ) from prowler.providers.common.models import Connection +ATLASSIAN_SITE_NAME_REGEX = re.compile( + r"\A[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\Z" +) + @dataclass class JiraConnection(Connection): @@ -665,11 +670,14 @@ class Jira: """ try: if self._using_basic_auth: + if not domain or not ATLASSIAN_SITE_NAME_REGEX.fullmatch(domain): + raise ValueError("Invalid Jira site name.") headers = self.get_headers(access_token) response = requests.get( f"https://{domain}.atlassian.net/_edge/tenant_info", headers=headers, timeout=self.REQUEST_TIMEOUT, + allow_redirects=False, ) response = response.json() return response.get("cloudId") diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index b71a58a570..de4c901fc3 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -56,7 +56,7 @@ class TestJiraIntegration: self.user_mail = "test_user_mail" self.api_token = "test_api_token" - self.domain = "test_domain" + self.domain = "test-domain" self.jira_integration_basic_auth = Jira( user_mail=self.user_mail, @@ -386,6 +386,35 @@ class TestJiraIntegration: assert mock_get.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT + @pytest.mark.parametrize( + "domain", + ( + "169.254.169.254#", + "internal/service", + "internal?target", + "internal\\target", + "internal:8000", + "user@internal", + ), + ) + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_cloud_id_basic_auth_rejects_invalid_domain(self, mock_get, domain): + with pytest.raises(JiraGetCloudIDError): + self.jira_integration_basic_auth.get_cloud_id(domain=domain) + + mock_get.assert_not_called() + + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_cloud_id_basic_auth_disables_redirects(self, mock_get): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"cloudId": "test_cloud_id"} + mock_get.return_value = mock_response + + self.jira_integration_basic_auth.get_cloud_id(domain=self.domain) + + assert mock_get.call_args.kwargs["allow_redirects"] is False + @patch("prowler.lib.outputs.jira.jira.requests.post") def test_refresh_access_token_sends_timeout(self, mock_post): """refresh_access_token must pass a request timeout."""