diff --git a/apiserver/plane/app/serializers/webhook.py b/apiserver/plane/app/serializers/webhook.py index 351b6fe7d..d5b3eeddd 100644 --- a/apiserver/plane/app/serializers/webhook.py +++ b/apiserver/plane/app/serializers/webhook.py @@ -1,3 +1,9 @@ +# Python imports +import urllib +import socket +import ipaddress +from urllib.parse import urlparse + # Third party imports from rest_framework import serializers @@ -9,6 +15,42 @@ from plane.db.models.webhook import validate_domain, validate_schema class WebhookSerializer(DynamicBaseSerializer): url = serializers.URLField(validators=[validate_schema, validate_domain]) + def validate(self, data): + url = data.get("url", None) + + # Extract the hostname from the URL + hostname = urlparse(url).hostname + if not hostname: + raise serializers.ValidationError({"url": "Invalid URL: No hostname found."}) + + # Resolve the hostname to IP addresses + try: + ip_addresses = socket.getaddrinfo(hostname, None) + except socket.gaierror: + raise serializers.ValidationError({"url": "Hostname could not be resolved."}) + + if not ip_addresses: + raise serializers.ValidationError({"url": "No IP addresses found for the hostname."}) + + for addr in ip_addresses: + ip = ipaddress.ip_address(addr[4][0]) + if ip.is_private or ip.is_loopback: + raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."}) + + # Additional validation for multiple request domains and their subdomains + request = self.context.get('request') + disallowed_domains = ['plane.so',] # Add your disallowed domains here + if request: + request_host = request.get_host().split(':')[0] # Remove port if present + disallowed_domains.append(request_host) + + # Check if hostname is a subdomain or exact match of any disallowed domain + if any(hostname == domain or hostname.endswith('.' + domain) for domain in disallowed_domains): + raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."}) + + return data + + class Meta: model = Webhook fields = "__all__" diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py index 7e3aa82b9..ba18dbbfa 100644 --- a/apiserver/plane/app/views/base.py +++ b/apiserver/plane/app/views/base.py @@ -65,6 +65,7 @@ class WebhookMixin: return response + class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): model = None diff --git a/apiserver/plane/app/views/user.py b/apiserver/plane/app/views/user.py index ed1178886..e45a3e2f4 100644 --- a/apiserver/plane/app/views/user.py +++ b/apiserver/plane/app/views/user.py @@ -48,7 +48,7 @@ class UserEndpoint(BaseViewSet): if WorkspaceMember.objects.filter(member=request.user, is_active=True).exists(): return Response( { - "error": "User cannot deactivate account as user is active in some workspaces" + "error": "You cannot deactivate account as you are a member in some workspaces." }, status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/app/views/webhook.py b/apiserver/plane/app/views/webhook.py index 74d23dd91..48608d583 100644 --- a/apiserver/plane/app/views/webhook.py +++ b/apiserver/plane/app/views/webhook.py @@ -20,9 +20,10 @@ class WebhookEndpoint(BaseAPIView): def post(self, request, slug): workspace = Workspace.objects.get(slug=slug) - try: - serializer = WebhookSerializer(data=request.data) + serializer = WebhookSerializer( + data=request.data, context={"request": request} + ) if serializer.is_valid(): serializer.save(workspace_id=workspace.id) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -79,6 +80,7 @@ class WebhookEndpoint(BaseAPIView): serializer = WebhookSerializer( webhook, data=request.data, + context={request: request}, partial=True, fields=( "id", diff --git a/apiserver/plane/app/views/workspace.py b/apiserver/plane/app/views/workspace.py index 637fc95b5..74ee07b04 100644 --- a/apiserver/plane/app/views/workspace.py +++ b/apiserver/plane/app/views/workspace.py @@ -600,7 +600,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): ): return Response( { - "error": "User is part of some projects where they are the only admin you should leave that project first" + "error": "User is a part of some projects where they are the only admin, they should either leave that project or promote another user to admin." }, status=status.HTTP_400_BAD_REQUEST, ) @@ -635,7 +635,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): ): return Response( { - "error": "You cannot leave the workspace as your the only admin of the workspace you will have to either delete the workspace or create an another admin" + "error": "You cannot leave the workspace as you are the only admin of the workspace you will have to either delete the workspace or promote another user to admin." }, status=status.HTTP_400_BAD_REQUEST, ) @@ -656,7 +656,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): ): return Response( { - "error": "User is part of some projects where they are the only admin you should leave that project first" + "error": "You are a part of some projects where you are the only admin, you should either leave the project or promote another user to admin." }, status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/db/models/webhook.py b/apiserver/plane/db/models/webhook.py index 6698ec5b0..ea2b508e5 100644 --- a/apiserver/plane/db/models/webhook.py +++ b/apiserver/plane/db/models/webhook.py @@ -16,7 +16,6 @@ def generate_token(): def validate_schema(value): parsed_url = urlparse(value) - print(parsed_url) if parsed_url.scheme not in ["http", "https"]: raise ValidationError("Invalid schema. Only HTTP and HTTPS are allowed.")