fix: harden Jira site handling (#12012)

This commit is contained in:
Adrián Peña
2026-07-16 11:48:12 +02:00
committed by GitHub
parent 0f31f1a3c2
commit a022ad5c7a
8 changed files with 209 additions and 9 deletions
@@ -0,0 +1 @@
Jira integration credentials only accept bare Atlassian site names containing letters, numbers, and hyphens
+57 -1
View File
@@ -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."""
+5 -5
View File
@@ -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
+88
View File
@@ -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:
@@ -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"],
@@ -0,0 +1 @@
Jira tenant information requests validate site names and do not follow redirects
+8
View File
@@ -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")
+30 -1
View File
@@ -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."""