diff --git a/apps/api/plane/api/serializers/issue.py b/apps/api/plane/api/serializers/issue.py
index 1e76daac2d..69c3562465 100644
--- a/apps/api/plane/api/serializers/issue.py
+++ b/apps/api/plane/api/serializers/issue.py
@@ -22,6 +22,11 @@ from plane.db.models import (
User,
EstimatePoint,
)
+from plane.utils.content_validator import (
+ validate_html_content,
+ validate_json_content,
+ validate_binary_data,
+)
from .base import BaseSerializer
from .cycle import CycleLiteSerializer, CycleSerializer
@@ -83,6 +88,22 @@ class IssueSerializer(BaseSerializer):
except Exception:
raise serializers.ValidationError("Invalid HTML passed")
+ # Validate description content for security
+ if data.get("description"):
+ is_valid, error_msg = validate_json_content(data["description"])
+ if not is_valid:
+ raise serializers.ValidationError({"description": error_msg})
+
+ if data.get("description_html"):
+ is_valid, error_msg = validate_html_content(data["description_html"])
+ if not is_valid:
+ raise serializers.ValidationError({"description_html": error_msg})
+
+ if data.get("description_binary"):
+ is_valid, error_msg = validate_binary_data(data["description_binary"])
+ if not is_valid:
+ raise serializers.ValidationError({"description_binary": error_msg})
+
# Validate assignees are from project
if data.get("assignees", []):
data["assignees"] = ProjectMember.objects.filter(
@@ -648,7 +669,6 @@ class IssueExpandSerializer(BaseSerializer):
assignees = serializers.SerializerMethodField()
state = StateLiteSerializer(read_only=True)
-
def get_labels(self, obj):
expand = self.context.get("expand", [])
if "labels" in expand:
@@ -666,7 +686,6 @@ class IssueExpandSerializer(BaseSerializer):
).data
return [ia.assignee_id for ia in obj.issue_assignee.all()]
-
class Meta:
model = Issue
fields = "__all__"
diff --git a/apps/api/plane/api/serializers/project.py b/apps/api/plane/api/serializers/project.py
index e0b6248403..6cb31ac82a 100644
--- a/apps/api/plane/api/serializers/project.py
+++ b/apps/api/plane/api/serializers/project.py
@@ -10,6 +10,10 @@ from plane.db.models import (
Estimate,
)
+from plane.utils.content_validator import (
+ validate_html_content,
+ validate_json_content,
+)
from .base import BaseSerializer
@@ -191,6 +195,29 @@ class ProjectSerializer(BaseSerializer):
"Default assignee should be a user in the workspace"
)
+ # Validate description content for security
+ if "description" in data and data["description"]:
+ # For Project, description might be text field, not JSON
+ if isinstance(data["description"], dict):
+ is_valid, error_msg = validate_json_content(data["description"])
+ if not is_valid:
+ raise serializers.ValidationError({"description": error_msg})
+
+ if "description_text" in data and data["description_text"]:
+ is_valid, error_msg = validate_json_content(data["description_text"])
+ if not is_valid:
+ raise serializers.ValidationError({"description_text": error_msg})
+
+ if "description_html" in data and data["description_html"]:
+ if isinstance(data["description_html"], dict):
+ is_valid, error_msg = validate_json_content(data["description_html"])
+ else:
+ is_valid, error_msg = validate_html_content(
+ str(data["description_html"])
+ )
+ if not is_valid:
+ raise serializers.ValidationError({"description_html": error_msg})
+
return data
def create(self, validated_data):
diff --git a/apps/api/plane/app/serializers/__init__.py b/apps/api/plane/app/serializers/__init__.py
index f0d98886e3..0116b20613 100644
--- a/apps/api/plane/app/serializers/__init__.py
+++ b/apps/api/plane/app/serializers/__init__.py
@@ -96,6 +96,7 @@ from .page import (
SubPageSerializer,
PageDetailSerializer,
PageVersionSerializer,
+ PageBinaryUpdateSerializer,
PageVersionDetailSerializer,
)
diff --git a/apps/api/plane/app/serializers/draft.py b/apps/api/plane/app/serializers/draft.py
index 86a5e3686e..53a593a1f7 100644
--- a/apps/api/plane/app/serializers/draft.py
+++ b/apps/api/plane/app/serializers/draft.py
@@ -21,6 +21,11 @@ from plane.db.models import (
ProjectMember,
EstimatePoint,
)
+from plane.utils.content_validator import (
+ validate_html_content,
+ validate_json_content,
+ validate_binary_data,
+)
from plane.app.permissions import ROLE
@@ -70,14 +75,21 @@ class DraftIssueCreateSerializer(BaseSerializer):
):
raise serializers.ValidationError("Start date cannot exceed target date")
- try:
- if attrs.get("description_html", None) is not None:
- parsed = html.fromstring(attrs["description_html"])
- parsed_str = html.tostring(parsed, encoding="unicode")
- attrs["description_html"] = parsed_str
+ # Validate description content for security
+ if "description" in attrs and attrs["description"]:
+ is_valid, error_msg = validate_json_content(attrs["description"])
+ if not is_valid:
+ raise serializers.ValidationError({"description": error_msg})
- except Exception:
- raise serializers.ValidationError("Invalid HTML passed")
+ if "description_html" in attrs and attrs["description_html"]:
+ is_valid, error_msg = validate_html_content(attrs["description_html"])
+ if not is_valid:
+ raise serializers.ValidationError({"description_html": error_msg})
+
+ if "description_binary" in attrs and attrs["description_binary"]:
+ is_valid, error_msg = validate_binary_data(attrs["description_binary"])
+ if not is_valid:
+ raise serializers.ValidationError({"description_binary": error_msg})
# Validate assignees are from project
if attrs.get("assignee_ids", []):
diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py
index 7f3301126d..d002de3390 100644
--- a/apps/api/plane/app/serializers/issue.py
+++ b/apps/api/plane/app/serializers/issue.py
@@ -41,6 +41,11 @@ from plane.db.models import (
ProjectMember,
EstimatePoint,
)
+from plane.utils.content_validator import (
+ validate_html_content,
+ validate_json_content,
+ validate_binary_data,
+)
class IssueFlatSerializer(BaseSerializer):
@@ -122,14 +127,21 @@ class IssueCreateSerializer(BaseSerializer):
):
raise serializers.ValidationError("Start date cannot exceed target date")
- try:
- if attrs.get("description_html", None) is not None:
- parsed = html.fromstring(attrs["description_html"])
- parsed_str = html.tostring(parsed, encoding="unicode")
- attrs["description_html"] = parsed_str
+ # Validate description content for security
+ if "description" in attrs and attrs["description"]:
+ is_valid, error_msg = validate_json_content(attrs["description"])
+ if not is_valid:
+ raise serializers.ValidationError({"description": error_msg})
- except Exception:
- raise serializers.ValidationError("Invalid HTML passed")
+ if "description_html" in attrs and attrs["description_html"]:
+ is_valid, error_msg = validate_html_content(attrs["description_html"])
+ if not is_valid:
+ raise serializers.ValidationError({"description_html": error_msg})
+
+ if "description_binary" in attrs and attrs["description_binary"]:
+ is_valid, error_msg = validate_binary_data(attrs["description_binary"])
+ if not is_valid:
+ raise serializers.ValidationError({"description_binary": error_msg})
# Validate assignees are from project
if attrs.get("assignee_ids", []):
diff --git a/apps/api/plane/app/serializers/page.py b/apps/api/plane/app/serializers/page.py
index 1fd2f4d3c8..78762e4b4e 100644
--- a/apps/api/plane/app/serializers/page.py
+++ b/apps/api/plane/app/serializers/page.py
@@ -1,8 +1,14 @@
# Third party imports
from rest_framework import serializers
+import base64
# Module imports
from .base import BaseSerializer
+from plane.utils.content_validator import (
+ validate_binary_data,
+ validate_html_content,
+ validate_json_content,
+)
from plane.db.models import (
Page,
PageLog,
@@ -186,3 +192,71 @@ class PageVersionDetailSerializer(BaseSerializer):
"updated_by",
]
read_only_fields = ["workspace", "page"]
+
+
+class PageBinaryUpdateSerializer(serializers.Serializer):
+ """Serializer for updating page binary description with validation"""
+
+ description_binary = serializers.CharField(required=False, allow_blank=True)
+ description_html = serializers.CharField(required=False, allow_blank=True)
+ description = serializers.JSONField(required=False, allow_null=True)
+
+ def validate_description_binary(self, value):
+ """Validate the base64-encoded binary data"""
+ if not value:
+ return value
+
+ try:
+ # Decode the base64 data
+ binary_data = base64.b64decode(value)
+
+ # Validate the binary data
+ is_valid, error_message = validate_binary_data(binary_data)
+ if not is_valid:
+ raise serializers.ValidationError(
+ f"Invalid binary data: {error_message}"
+ )
+
+ return binary_data
+ except Exception as e:
+ if isinstance(e, serializers.ValidationError):
+ raise
+ raise serializers.ValidationError("Failed to decode base64 data")
+
+ def validate_description_html(self, value):
+ """Validate the HTML content"""
+ if not value:
+ return value
+
+ # Use the validation function from utils
+ is_valid, error_message = validate_html_content(value)
+ if not is_valid:
+ raise serializers.ValidationError(error_message)
+
+ return value
+
+ def validate_description(self, value):
+ """Validate the JSON description"""
+ if not value:
+ return value
+
+ # Use the validation function from utils
+ is_valid, error_message = validate_json_content(value)
+ if not is_valid:
+ raise serializers.ValidationError(error_message)
+
+ return value
+
+ def update(self, instance, validated_data):
+ """Update the page instance with validated data"""
+ if "description_binary" in validated_data:
+ instance.description_binary = validated_data.get("description_binary")
+
+ if "description_html" in validated_data:
+ instance.description_html = validated_data.get("description_html")
+
+ if "description" in validated_data:
+ instance.description = validated_data.get("description")
+
+ instance.save()
+ return instance
diff --git a/apps/api/plane/app/serializers/project.py b/apps/api/plane/app/serializers/project.py
index 813cb068f4..dfa541d9f1 100644
--- a/apps/api/plane/app/serializers/project.py
+++ b/apps/api/plane/app/serializers/project.py
@@ -13,6 +13,11 @@ from plane.db.models import (
DeployBoard,
ProjectPublicMember,
)
+from plane.utils.content_validator import (
+ validate_html_content,
+ validate_json_content,
+ validate_binary_data,
+)
class ProjectSerializer(BaseSerializer):
@@ -58,6 +63,32 @@ class ProjectSerializer(BaseSerializer):
return identifier
+ def validate(self, data):
+ # Validate description content for security
+ if "description" in data and data["description"]:
+ # For Project, description might be text field, not JSON
+ if isinstance(data["description"], dict):
+ is_valid, error_msg = validate_json_content(data["description"])
+ if not is_valid:
+ raise serializers.ValidationError({"description": error_msg})
+
+ if "description_text" in data and data["description_text"]:
+ is_valid, error_msg = validate_json_content(data["description_text"])
+ if not is_valid:
+ raise serializers.ValidationError({"description_text": error_msg})
+
+ if "description_html" in data and data["description_html"]:
+ if isinstance(data["description_html"], dict):
+ is_valid, error_msg = validate_json_content(data["description_html"])
+ else:
+ is_valid, error_msg = validate_html_content(
+ str(data["description_html"])
+ )
+ if not is_valid:
+ raise serializers.ValidationError({"description_html": error_msg})
+
+ return data
+
def create(self, validated_data):
workspace_id = self.context["workspace_id"]
diff --git a/apps/api/plane/app/serializers/workspace.py b/apps/api/plane/app/serializers/workspace.py
index 60866cade2..ec4c4bf63e 100644
--- a/apps/api/plane/app/serializers/workspace.py
+++ b/apps/api/plane/app/serializers/workspace.py
@@ -24,6 +24,11 @@ from plane.db.models import (
)
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.utils.url import contains_url
+from plane.utils.content_validator import (
+ validate_html_content,
+ validate_json_content,
+ validate_binary_data,
+)
# Django imports
from django.core.validators import URLValidator
@@ -312,6 +317,25 @@ class StickySerializer(BaseSerializer):
read_only_fields = ["workspace", "owner"]
extra_kwargs = {"name": {"required": False}}
+ def validate(self, data):
+ # Validate description content for security
+ if "description" in data and data["description"]:
+ is_valid, error_msg = validate_json_content(data["description"])
+ if not is_valid:
+ raise serializers.ValidationError({"description": error_msg})
+
+ if "description_html" in data and data["description_html"]:
+ is_valid, error_msg = validate_html_content(data["description_html"])
+ if not is_valid:
+ raise serializers.ValidationError({"description_html": error_msg})
+
+ if "description_binary" in data and data["description_binary"]:
+ is_valid, error_msg = validate_binary_data(data["description_binary"])
+ if not is_valid:
+ raise serializers.ValidationError({"description_binary": error_msg})
+
+ return data
+
class WorkspaceUserPreferenceSerializer(BaseSerializer):
class Meta:
diff --git a/apps/api/plane/app/views/page/base.py b/apps/api/plane/app/views/page/base.py
index cb9b0e0923..96de81abfb 100644
--- a/apps/api/plane/app/views/page/base.py
+++ b/apps/api/plane/app/views/page/base.py
@@ -25,6 +25,7 @@ from plane.app.serializers import (
PageSerializer,
SubPageSerializer,
PageDetailSerializer,
+ PageBinaryUpdateSerializer,
)
from plane.db.models import (
Page,
@@ -538,32 +539,27 @@ class PagesDescriptionViewSet(BaseViewSet):
{"description_html": page.description_html}, cls=DjangoJSONEncoder
)
- # Get the base64 data from the request
- base64_data = request.data.get("description_binary")
-
- # If base64 data is provided
- if base64_data:
- # Decode the base64 data to bytes
- new_binary_data = base64.b64decode(base64_data)
- # capture the page transaction
+ # Use serializer for validation and update
+ serializer = PageBinaryUpdateSerializer(page, data=request.data, partial=True)
+ if serializer.is_valid():
+ # Capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data, old_value=existing_instance, page_id=pk
)
- # Store the updated binary data
- page.description_binary = new_binary_data
- page.description_html = request.data.get("description_html")
- page.description = request.data.get("description")
- page.save()
- # Return a success response
+
+ # Update the page using serializer
+ updated_page = serializer.save()
+
+ # Run background tasks
page_version.delay(
- page_id=page.id,
+ page_id=updated_page.id,
existing_instance=existing_instance,
user_id=request.user.id,
)
return Response({"message": "Updated successfully"})
else:
- return Response({"error": "No binary data provided"})
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class PageDuplicateEndpoint(BaseAPIView):
diff --git a/apps/api/plane/space/serializer/issue.py b/apps/api/plane/space/serializer/issue.py
index e1445b4e63..3549e76262 100644
--- a/apps/api/plane/space/serializer/issue.py
+++ b/apps/api/plane/space/serializer/issue.py
@@ -28,6 +28,11 @@ from plane.db.models import (
IssueVote,
IssueRelation,
)
+from plane.utils.content_validator import (
+ validate_html_content,
+ validate_json_content,
+ validate_binary_data,
+)
class IssueStateFlatSerializer(BaseSerializer):
@@ -283,6 +288,23 @@ class IssueCreateSerializer(BaseSerializer):
and data.get("start_date", None) > data.get("target_date", None)
):
raise serializers.ValidationError("Start date cannot exceed target date")
+
+ # Validate description content for security
+ if "description" in data and data["description"]:
+ is_valid, error_msg = validate_json_content(data["description"])
+ if not is_valid:
+ raise serializers.ValidationError({"description": error_msg})
+
+ if "description_html" in data and data["description_html"]:
+ is_valid, error_msg = validate_html_content(data["description_html"])
+ if not is_valid:
+ raise serializers.ValidationError({"description_html": error_msg})
+
+ if "description_binary" in data and data["description_binary"]:
+ is_valid, error_msg = validate_binary_data(data["description_binary"])
+ if not is_valid:
+ raise serializers.ValidationError({"description_binary": error_msg})
+
return data
def create(self, validated_data):
diff --git a/apps/api/plane/utils/content_validator.py b/apps/api/plane/utils/content_validator.py
new file mode 100644
index 0000000000..7b9932a35b
--- /dev/null
+++ b/apps/api/plane/utils/content_validator.py
@@ -0,0 +1,357 @@
+# Python imports
+import base64
+import json
+import re
+
+
+# Maximum allowed size for binary data (10MB)
+MAX_SIZE = 10 * 1024 * 1024
+
+# Maximum recursion depth to prevent stack overflow
+MAX_RECURSION_DEPTH = 20
+
+# Dangerous text patterns that could indicate XSS or script injection
+DANGEROUS_TEXT_PATTERNS = [
+ r"",
+ r"javascript\s*:",
+ r"data\s*:\s*text/html",
+ r"eval\s*\(",
+ r"document\s*\.",
+ r"window\s*\.",
+ r"location\s*\.",
+]
+
+# Dangerous attribute patterns for HTML attributes
+DANGEROUS_ATTR_PATTERNS = [
+ r"javascript\s*:",
+ r"data\s*:\s*text/html",
+ r"eval\s*\(",
+ r"alert\s*\(",
+ r"document\s*\.",
+ r"window\s*\.",
+]
+
+# Suspicious patterns for binary data content
+SUSPICIOUS_BINARY_PATTERNS = [
+ "]*>",
+ r"",
+ # JavaScript URLs in various attributes
+ r'(?:href|src|action)\s*=\s*["\']?\s*javascript:',
+ # Data URLs with text/html (potential XSS)
+ r'(?:href|src|action)\s*=\s*["\']?\s*data:text/html',
+ # Dangerous event handlers with JavaScript-like content
+ r'on(?:load|error|click|focus|blur|change|submit|reset|select|resize|scroll|unload|beforeunload|hashchange|popstate|storage|message|offline|online)\s*=\s*["\']?[^"\']*(?:javascript|alert|eval|document\.|window\.|location\.|history\.)[^"\']*["\']?',
+ # Object and embed tags that could load external content
+ r"<(?:object|embed)[^>]*(?:data|src)\s*=",
+ # Base tag that could change relative URL resolution
+ r"]*href\s*=",
+ # Dangerous iframe sources
+ r'