CVE-2024-31461 (published 2024-04-10T18:15Z)

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.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/makeplane/plane
commit_details: repo_url: https://github.com/makeplane/plane
author: Nikhil
author_date: 2024-01-08 23:27:09+05:30
msg: 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
repository: repo_url: https://github.com/makeplane/plane
repo_name: makeplane/plane
description: 🔥 🔥 🔥 Open Source JIRA, Linear and Asana Alternative. Plane helps you track your issues, epics, and product roadmaps in the simplest way possible.
repo_language: TypeScript
owner: makeplane
file_changes: filename: codeql.yml
change_type: ModificationType.MODIFY
diff: @@ -2,10 +2,10 @@ 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'
 

programming_language: Markdown
filename: importer.py
change_type: ModificationType.MODIFY
diff: @@ -35,14 +35,13 @@ from plane.app.serializers import (
     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)
@@ -122,6 +121,7 @@ class ImportServiceEndpoint(BaseAPIView):
     permission_classes = [
         WorkSpaceAdminPermission,
     ]
+
     def post(self, request, slug, service):
         project_id = request.data.get("project_id", False)
 
@@ -174,6 +174,21 @@ class ImportServiceEndpoint(BaseAPIView):
             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"},
@@ -221,9 +236,7 @@ class ImportServiceEndpoint(BaseAPIView):
         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
@@ -241,9 +254,7 @@ class ImportServiceEndpoint(BaseAPIView):
         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()
@@ -479,9 +490,7 @@ class BulkImportModulesEndpoint(BaseAPIView):
                 [
                     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"
                         ),

programming_language: Python
filename: jira.py
change_type: ModificationType.MODIFY
diff: @@ -2,13 +2,31 @@ 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"]
@@ -18,7 +36,7 @@ def jira_project_issue_summary(email, api_token, project_key, hostname):
             "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()

programming_language: Python
filename: single-integration-card.tsx
change_type: ModificationType.MODIFY
diff: @@ -139,7 +139,7 @@ export const SingleIntegrationCard: React.FC<Props> = observer(({ integration })
               variant="danger"
               onClick={() => {
                 if (!isUserAdmin) return;
-                handleRemoveIntegration;
+                handleRemoveIntegration();
               }}
               disabled={!isUserAdmin}
               loading={deletingIntegration}

programming_language: TypeScript
filename: project.service.ts
change_type: ModificationType.MODIFY
diff: @@ -86,7 +86,11 @@ export class ProjectService extends APIService {
   }
 
   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;

programming_language: TypeScript
repo_url: https://github.com/makeplane/plane
commit_details: repo_url: https://github.com/makeplane/plane
author: Nikhil
author_date: 2024-01-09 20:40:23+05:30
msg: fix: update jira summary endpoints (#3333)

* dev: update jira summary endpoints

* dev: update jira project key validations

* dev: updated key length
repository: repo_url: https://github.com/makeplane/plane
repo_name: makeplane/plane
description: 🔥 🔥 🔥 Open Source JIRA, Linear and Asana Alternative. Plane helps you track your issues, epics, and product roadmaps in the simplest way possible.
repo_language: TypeScript
owner: makeplane
file_changes: filename: jira.py
change_type: ModificationType.MODIFY
diff: @@ -1,53 +1,89 @@
 import requests
+import re
 from requests.auth import HTTPBasicAuth
 from sentry_sdk import capture_exception
+from urllib.parse import urlparse, urljoin
 
-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
+    allowed_domains = ["atl-paas.net", "atlassian.com", "atlassian.net", "jira.com"]
+    parsed_uri = urlparse(f"https://{hostname}")
+    domain = parsed_uri.netloc.split(":")[0]  # Ensures no port is included
+    base_domain = ".".join(domain.split(".")[-2:])
+    return base_domain in allowed_domains
 
-    # Check if the base domain is in the allowed list
-    return base_domain in allowed_lists
+
+def is_valid_project_key(project_key):
+    if project_key:
+        project_key = project_key.strip().upper()
+        # Adjust the regular expression as needed based on your specific requirements.
+        if len(project_key) > 30:
+            return False
+        # Check the validity of the key as well
+        pattern = re.compile(r'^[A-Z0-9]{1,10}$')
+        return pattern.match(project_key) is not None
+    else:
+        False
+
+def generate_valid_project_key(project_key):
+    return project_key.strip().upper()
+
+def generate_url(hostname, path):
+    if not is_allowed_hostname(hostname):
+        raise ValueError("Invalid or unauthorized hostname")
+    return urljoin(f"https://{hostname}", path)
 
 
 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"}
 
+        if not is_valid_project_key(project_key):
+            return {"error": "Invalid project key"}
+
         auth = HTTPBasicAuth(email, api_token)
         headers = {"Accept": "application/json"}
+        
+        # make the project key upper case
+        project_key = generate_valid_project_key(project_key)
 
-        issue_url = f"https://{hostname}/rest/api/3/search?jql=project={project_key} AND issuetype!=Epic"
+        # issues
+        issue_url = generate_url(
+            hostname,
+            f"/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"
+        # modules
+        module_url = generate_url(
+            hostname, f"/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/project/${project_key}/statuses"
+        # status
+        status_url = generate_url(
+            hostname, f"/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}"
+        # labels
+        labels_url = generate_url(
+            hostname, f"/rest/api/3/label/?jql=project={project_key}"
+        )
         labels_response = requests.request(
             "GET", labels_url, headers=headers, auth=auth
         ).json()["total"]
 
-        users_url = (
-            f"https://{hostname}/rest/api/3/users/search?jql=project={project_key}"
+        # users
+        users_url = generate_url(
+            hostname, f"/rest/api/3/users/search?jql=project={project_key}"
         )
         users_response = requests.request(
             "GET", users_url, headers=headers, auth=auth

programming_language: Python