From ca84c156587622274e8004ad092c542c446b24b5 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Wed, 22 Nov 2023 13:21:44 +0530 Subject: [PATCH 1/2] dev: webhook validation and fix webhook flow for app --- apiserver/plane/app/serializers/webhook.py | 42 ++++++++++++++++++++++ apiserver/plane/app/views/base.py | 12 ++++--- apiserver/plane/app/views/webhook.py | 6 ++-- apiserver/plane/db/models/webhook.py | 1 - 4 files changed, 54 insertions(+), 7 deletions(-) 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 de7bafd57..ba18dbbfa 100644 --- a/apiserver/plane/app/views/base.py +++ b/apiserver/plane/app/views/base.py @@ -46,22 +46,26 @@ class WebhookMixin: def finalize_response(self, request, response, *args, **kwargs): response = super().finalize_response(request, response, *args, **kwargs) - if ( self.webhook_event - and self.request.method in ["POST", "PATCH", "DELETE"] + and self.request.method in ["POST", "PATCH"] and response.status_code in [200, 201, 204] ): + # Get the id + object_id = ( + response.data.get("id") if isinstance(response.data, dict) else None + ) + send_webhook.delay( event=self.webhook_event, - event_data=json.dumps(response.data, cls=DjangoJSONEncoder), + event_id=object_id, action=self.request.method, slug=self.workspace_slug, ) - return response + class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): model = None 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/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.") From 4bd466c16b886258f12fa4c0ee2067f2b5f4694a Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Wed, 22 Nov 2023 13:22:04 +0530 Subject: [PATCH 2/2] dev: error messages for deactivation --- apiserver/plane/app/views/user.py | 2 +- apiserver/plane/app/views/workspace.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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/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, )