CVE-2024-31461: Insufficient Information (NVD-CWE-noinfo)
value: Plane, an open-source project management tool, has a Server-Side Request Forgery (SSRF) vulnerability in versions prior to 0.17-dev. This issue may allow an attacker to send arbitrary requests from the server hosting the application, potentially leading to unauthorized access to internal systems. The impact of this vulnerability includes, but is not limited to, unauthorized access to internal services accessible from the server, potential leakage of sensitive information from internal services, manipulation of internal systems by interacting with internal APIs. Version 0.17-dev contains a patch for this issue. Those who are unable to update immediately may mitigate the issue by restricting outgoing network connections from servers hosting the application to essential services only and/or implementing strict input validation on URLs or parameters that are used to generate server-side requests.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-04-10T18:15Z
Repository: https://github.com/makeplane/plane
Language: Python
Files changed: .github/workflows/codeql.yml, apiserver/plane/app/views/importer.py, apiserver/plane/utils/importers/jira.py, web/components/integration/single-integration-card.tsx, web/services/project/project.service.ts
Commit date: 2024-01-08 23:27:09 +0530
Commit message: fix: jira importer validations (#3323)

* fix: jira importer validations

* dev: update validation for cloud hostname

* dev: update the function to be used externally

* dev: update codeql workflow

* dev: update repository selection api
Diff: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 29fbde45..9f6ab1bf 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -1,13 +1,13 @@
 name: "CodeQL"
 
 on:
   push:
-    branches: [ 'develop', 'hot-fix', 'stage-release' ]
+    branches: [ 'develop', 'preview', 'master' ]
   pull_request:
     # The branches below must be a subset of the branches above
-    branches: [ 'develop' ]
+    branches: [ 'develop', 'preview', 'master' ]
   schedule:
     - cron: '53 19 * * 5'
 
 jobs:
   analyze:
diff --git a/apiserver/plane/app/views/importer.py b/apiserver/plane/app/views/importer.py
index b99d663e..00d698ac 100644
--- a/apiserver/plane/app/views/importer.py
+++ b/apiserver/plane/app/views/importer.py
@@ -33,18 +33,17 @@ from plane.app.serializers import (
     ImporterSerializer,
     IssueFlatSerializer,
     ModuleSerializer,
 )
 from plane.utils.integrations.github import get_github_repo_details
-from plane.utils.importers.jira import jira_project_issue_summary
+from plane.utils.importers.jira import jira_project_issue_summary, is_allowed_hostname
 from plane.bgtasks.importer_task import service_importer
 from plane.utils.html_processor import strip_tags
 from plane.app.permissions import WorkSpaceAdminPermission
 
 
 class ServiceIssueImportSummaryEndpoint(BaseAPIView):
-
     def get(self, request, slug, service):
         if service == "github":
             owner = request.GET.get("owner", False)
             repo = request.GET.get("repo", False)
 
@@ -120,10 +119,11 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView):
 
 class ImportServiceEndpoint(BaseAPIView):
     permission_classes = [
         WorkSpaceAdminPermission,
     ]
+
     def post(self, request, slug, service):
         project_id = request.data.get("project_id", False)
 
         if not project_id:
             return Response(
@@ -172,10 +172,25 @@ class ImportServiceEndpoint(BaseAPIView):
 
         if service == "jira":
             data = request.data.get("data", False)
             metadata = request.data.get("metadata", False)
             config = request.data.get("config", False)
+
+            cloud_hostname = metadata.get("cloud_hostname", False)
+
+            if not cloud_hostname:
+                return Response(
+                    {"error": "Cloud hostname is required"},
+                    status=status.HTTP_400_BAD_REQUEST,
+                )
+
+            if not is_allowed_hostname(cloud_hostname):
+                return Response(
+                    {"error": "Hostname is not a valid hostname."},
+                    status=status.HTTP_400_BAD_REQUEST,
+                )
+
             if not data or not metadata:
                 return Response(
                     {"error": "Data, config and metadata are required"},
                     status=status.HTTP_400_BAD_REQUEST,
                 )
@@ -219,13 +234,11 @@ class ImportServiceEndpoint(BaseAPIView):
         )
         serializer = ImporterSerializer(imports, many=True)
         return Response(serializer.data)
 
     def delete(self, request, slug, service, pk):
-        importer = Importer.objects.get(
-            pk=pk, service=service, workspace__slug=slug
-        )
+        importer = Importer.objects.get(pk=pk, service=service, workspace__slug=slug)
 
         if importer.imported_data is not None:
             # Delete all imported Issues
             imported_issues = importer.imported_data.get("issues", [])
             Issue.issue_objects.filter(id__in=imported_issues).delete()
@@ -239,13 +252,11 @@ class ImportServiceEndpoint(BaseAPIView):
                 Module.objects.filter(id__in=imported_modules).delete()
         importer.delete()
         return Response(status=status.HTTP_204_NO_CONTENT)
 
     def patch(self, request, slug, service, pk):
-        importer = Importer.objects.get(
-            pk=pk, service=service, workspace__slug=slug
-        )
+        importer = Importer.objects.get(pk=pk, service=service, workspace__slug=slug)
         serializer = ImporterSerializer(importer, data=request.data, partial=True)
         if serializer.is_valid():
             serializer.save()
             return Response(serializer.data, status=status.HTTP_200_OK)
         return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -477,13 +488,11 @@ class BulkImportModulesEndpoint(BaseAPIView):
         if len(modules) == len(modules_data):
             _ = ModuleLink.objects.bulk_create(
                 [
                     ModuleLink(
                         module=module,
-                        url=module_data.get("link", {}).get(
-                            "url", "https://plane.so"
-                        ),
+                        url=module_data.get("link", {}).get("url", "https://plane.so"),
                         title=module_data.get("link", {}).get(
                             "title", "Original Issue"
                         ),
                         project_id=project_id,
                         workspace_id=project.workspace_id,
diff --git a/apiserver/plane/utils/importers/jira.py b/apiserver/plane/utils/importers/jira.py
index b427ba14..3081096f 100644
--- a/apiserver/plane/utils/importers/jira.py
+++ b/apiserver/plane/utils/importers/jira.py
@@ -1,26 +1,44 @@
 import requests
 from requests.auth import HTTPBasicAuth
 from sentry_sdk import capture_exception
 
+from urllib.parse import urlparse
+
+def is_allowed_hostname(hostname):
+    allowed_lists = ["atl-paas.net", "atlassian.com", "atlassian.net", "jira.com"]
+    # Extract the base domain from the hostname
+    parsed_uri = urlparse(f"https://{hostname}")  # Add scheme for urlparse to work properly
+    domain = parsed_uri.netloc.split(":")[0]  # Removes port number if included
+    base_domain = ".".join(domain.split(".")[-2:])  # Extract base domain
+
+    # Check if the base domain is in the allowed list
+    return base_domain in allowed_lists
+
 
 def jira_project_issue_summary(email, api_token, project_key, hostname):
     try:
+        
+
+        if not is_allowed_hostname(hostname):
+            print("Errored Hostname")
+            return {"error": "Invalid or unauthorized hostname"}
+
         auth = HTTPBasicAuth(email, api_token)
         headers = {"Accept": "application/json"}
 
-        issue_url = f"https://{hostname}/rest/api/3/search?jql=project={project_key} AND issuetype=Story"
+        issue_url = f"https://{hostname}/rest/api/3/search?jql=project={project_key} AND issuetype!=Epic"
         issue_response = requests.request(
             "GET", issue_url, headers=headers, auth=auth
         ).json()["total"]
 
         module_url = f"https://{hostname}/rest/api/3/search?jql=project={project_key} AND issuetype=Epic"
         module_response = requests.request(
             "GET", module_url, headers=headers, auth=auth
         ).json()["total"]
 
-        status_url = f"https://{hostname}/rest/api/3/status/?jql=project={project_key}"
+        status_url = f"https://{hostname}/rest/api/3/project/${project_key}/statuses"
         status_response = requests.request(
             "GET", status_url, headers=headers, auth=auth
         ).json()
 
         labels_url = f"https://{hostname}/rest/api/3/label/?jql=project={project_key}"
diff --git a/web/components/integration/single-integration-card.tsx b/web/components/integration/single-integration-card.tsx
index e07f580e..6692bacd 100644
--- a/web/components/integration/single-integration-card.tsx
+++ b/web/components/integration/single-integration-card.tsx
@@ -137,11 +137,11 @@ export const SingleIntegrationCard: React.FC<Props> = observer(({ integration })
             <Button
               className={`${!isUserAdmin ? "hover:cursor-not-allowed" : ""}`}
               variant="danger"
               onClick={() => {
                 if (!isUserAdmin) return;
-                handleRemoveIntegration;
+                handleRemoveIntegration();
               }}
               disabled={!isUserAdmin}
               loading={deletingIntegration}
             >
               {deletingIntegration ? "Uninstalling..." : "Uninstall"}
diff --git a/web/services/project/project.service.ts b/web/services/project/project.service.ts
index 7e8821cf..501abe67 100644
--- a/web/services/project/project.service.ts
+++ b/web/services/project/project.service.ts
@@ -84,11 +84,15 @@ export class ProjectService extends APIService {
         throw error?.response?.data;
       });
   }
 
   async getGithubRepositories(url: string): Promise<GithubRepositoriesResponse> {
-    return this.request(url)
+    return this.request({
+      method: "get",
+      url,
+      headers: this.getAccessToken() ? this.getHeaders() : {},
+    })
       .then((response) => response?.data)
       .catch((error) => {
         throw error?.response?.data;
       });
   }

Vulnerable code:     branches: [ 'develop', 'hot-fix', 'stage-release' ]
    branches: [ 'develop' ]
from plane.utils.importers.jira import jira_project_issue_summary

        importer = Importer.objects.get(
            pk=pk, service=service, workspace__slug=slug
        )
        importer = Importer.objects.get(
            pk=pk, service=service, workspace__slug=slug
        )
                        url=module_data.get("link", {}).get(
                            "url", "https://plane.so"
                        ),
        issue_url = f"https://{hostname}/rest/api/3/search?jql=project={project_key} AND issuetype=Story"
        status_url = f"https://{hostname}/rest/api/3/status/?jql=project={project_key}"
                handleRemoveIntegration;
    return this.request(url)
Fixed code:     branches: [ 'develop', 'preview', 'master' ]
    branches: [ 'develop', 'preview', 'master' ]
from plane.utils.importers.jira import jira_project_issue_summary, is_allowed_hostname


            cloud_hostname = metadata.get("cloud_hostname", False)

            if not cloud_hostname:
                return Response(
                    {"error": "Cloud hostname is required"},
                    status=status.HTTP_400_BAD_REQUEST,
                )

            if not is_allowed_hostname(cloud_hostname):
                return Response(
                    {"error": "Hostname is not a valid hostname."},
                    status=status.HTTP_400_BAD_REQUEST,
                )

        importer = Importer.objects.get(pk=pk, service=service, workspace__slug=slug)
        importer = Importer.objects.get(pk=pk, service=service, workspace__slug=slug)
                        url=module_data.get("link", {}).get("url", "https://plane.so"),
from urllib.parse import urlparse

def is_allowed_hostname(hostname):
    allowed_lists = ["atl-paas.net", "atlassian.com", "atlassian.net", "jira.com"]
    # Extract the base domain from the hostname
    parsed_uri = urlparse(f"https://{hostname}")  # Add scheme for urlparse to work properly
    domain = parsed_uri.netloc.split(":")[0]  # Removes port number if included
    base_domain = ".".join(domain.split(".")[-2:])  # Extract base domain

    # Check if the base domain is in the allowed list
    return base_domain in allowed_lists

        

        if not is_allowed_hostname(hostname):
            print("Errored Hostname")
            return {"error": "Invalid or unauthorized hostname"}

        issue_url = f"https://{hostname}/rest/api/3/search?jql=project={project_key} AND issuetype!=Epic"
        status_url = f"https://{hostname}/rest/api/3/project/${project_key}/statuses"
                handleRemoveIntegration();
    return this.request({
      method: "get",
      url,
      headers: this.getAccessToken() ? this.getHeaders() : {},
    })