chore: api webhooks validation (#2928)

* chore: api webhooks update

* chore: webhooks signature validation
This commit is contained in:
Bavisetti Narayan 2023-11-29 14:13:03 +05:30 committed by GitHub
parent cdc4fd27a5
commit 6a4f521b47
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 62 additions and 31 deletions

View File

@ -55,7 +55,6 @@ class ModuleSerializer(BaseSerializer):
raise serializers.ValidationError("Start date cannot exceed target date") raise serializers.ValidationError("Start date cannot exceed target date")
if data.get("members", []): if data.get("members", []):
print(data.get("members"))
data["members"] = ProjectMember.objects.filter( data["members"] = ProjectMember.objects.filter(
project_id=self.context.get("project_id"), project_id=self.context.get("project_id"),
member_id__in=data["members"], member_id__in=data["members"],

View File

@ -10,7 +10,7 @@ urlpatterns = [
name="inbox-issue", name="inbox-issue",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:issue_id>/",
InboxIssueAPIEndpoint.as_view(), InboxIssueAPIEndpoint.as_view(),
name="inbox-issue", name="inbox-issue",
), ),

View File

@ -60,9 +60,9 @@ class InboxIssueAPIEndpoint(BaseAPIView):
.order_by(self.kwargs.get("order_by", "-created_at")) .order_by(self.kwargs.get("order_by", "-created_at"))
) )
def get(self, request, slug, project_id, pk=None): def get(self, request, slug, project_id, issue_id=None):
if pk: if issue_id:
inbox_issue_queryset = self.get_queryset().get(pk=pk) inbox_issue_queryset = self.get_queryset().get(issue_id=issue_id)
inbox_issue_data = InboxIssueSerializer( inbox_issue_data = InboxIssueSerializer(
inbox_issue_queryset, inbox_issue_queryset,
fields=self.fields, fields=self.fields,
@ -163,7 +163,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
serializer = InboxIssueSerializer(inbox_issue) serializer = InboxIssueSerializer(inbox_issue)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
def patch(self, request, slug, project_id, pk): def patch(self, request, slug, project_id, issue_id):
inbox = Inbox.objects.filter( inbox = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id workspace__slug=slug, project_id=project_id
).first() ).first()
@ -184,7 +184,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
# Get the inbox issue # Get the inbox issue
inbox_issue = InboxIssue.objects.get( inbox_issue = InboxIssue.objects.get(
pk=pk, issue_id=issue_id,
workspace__slug=slug, workspace__slug=slug,
project_id=project_id, project_id=project_id,
inbox_id=inbox.id, inbox_id=inbox.id,
@ -212,7 +212,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
if bool(issue_data): if bool(issue_data):
issue = Issue.objects.get( issue = Issue.objects.get(
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id pk=issue_id, workspace__slug=slug, project_id=project_id
) )
# Only allow guests and viewers to edit name and description # Only allow guests and viewers to edit name and description
if project_member.role <= 10: if project_member.role <= 10:
@ -236,7 +236,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
type="issue.activity.updated", type="issue.activity.updated",
requested_data=requested_data, requested_data=requested_data,
actor_id=str(request.user.id), actor_id=str(request.user.id),
issue_id=str(issue.id), issue_id=str(issue_id),
project_id=str(project_id), project_id=str(project_id),
current_instance=json.dumps( current_instance=json.dumps(
IssueSerializer(current_instance).data, IssueSerializer(current_instance).data,
@ -261,7 +261,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
# Update the issue state if the issue is rejected or marked as duplicate # Update the issue state if the issue is rejected or marked as duplicate
if serializer.data["status"] in [-1, 2]: if serializer.data["status"] in [-1, 2]:
issue = Issue.objects.get( issue = Issue.objects.get(
pk=inbox_issue.issue_id, pk=issue_id,
workspace__slug=slug, workspace__slug=slug,
project_id=project_id, project_id=project_id,
) )
@ -275,7 +275,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
# Update the issue state if it is accepted # Update the issue state if it is accepted
if serializer.data["status"] in [1]: if serializer.data["status"] in [1]:
issue = Issue.objects.get( issue = Issue.objects.get(
pk=inbox_issue.issue_id, pk=issue_id,
workspace__slug=slug, workspace__slug=slug,
project_id=project_id, project_id=project_id,
) )
@ -297,7 +297,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK
) )
def delete(self, request, slug, project_id, pk): def delete(self, request, slug, project_id, issue_id):
inbox = Inbox.objects.filter( inbox = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id workspace__slug=slug, project_id=project_id
).first() ).first()
@ -318,7 +318,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
# Get the inbox issue # Get the inbox issue
inbox_issue = InboxIssue.objects.get( inbox_issue = InboxIssue.objects.get(
pk=pk, issue_id=issue_id,
workspace__slug=slug, workspace__slug=slug,
project_id=project_id, project_id=project_id,
inbox_id=inbox.id, inbox_id=inbox.id,
@ -345,7 +345,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
if inbox_issue.status in [-2, -1, 0, 2]: if inbox_issue.status in [-2, -1, 0, 2]:
# Delete the issue also # Delete the issue also
Issue.objects.filter( Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk=inbox_issue.issue_id workspace__slug=slug, project_id=project_id, pk=issue_id
).delete() ).delete()
inbox_issue.delete() inbox_issue.delete()

View File

@ -15,8 +15,8 @@ from plane.db.models.webhook import validate_domain, validate_schema
class WebhookSerializer(DynamicBaseSerializer): class WebhookSerializer(DynamicBaseSerializer):
url = serializers.URLField(validators=[validate_schema, validate_domain]) url = serializers.URLField(validators=[validate_schema, validate_domain])
def validate(self, data): def create(self, validated_data):
url = data.get("url", None) url = validated_data.get("url", None)
# Extract the hostname from the URL # Extract the hostname from the URL
hostname = urlparse(url).hostname hostname = urlparse(url).hostname
@ -48,8 +48,42 @@ class WebhookSerializer(DynamicBaseSerializer):
if any(hostname == domain or hostname.endswith('.' + domain) for domain in disallowed_domains): 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."}) raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
return data return Webhook.objects.create(**validated_data)
def update(self, instance, validated_data):
url = validated_data.get("url", None)
if url:
# 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 super().update(instance, validated_data)
class Meta: class Meta:
model = Webhook model = Webhook

View File

@ -10,7 +10,6 @@ from sentry_sdk import capture_exception
@shared_task @shared_task
def auth_events(user, email, user_agent, ip, event_name, medium, first_time): def auth_events(user, email, user_agent, ip, event_name, medium, first_time):
print(user, email, user_agent, ip, event_name, medium, first_time)
try: try:
posthog = Posthog(settings.POSTHOG_API_KEY, host=settings.POSTHOG_HOST) posthog = Posthog(settings.POSTHOG_API_KEY, host=settings.POSTHOG_HOST)
posthog.capture( posthog.capture(

View File

@ -90,17 +90,6 @@ def webhook_task(self, webhook, slug, event, event_data, action):
else None else None
) )
# Use HMAC for generating signature
if webhook.secret_key:
event_data_json = json.dumps(event_data) if event_data is not None else "{}"
hmac_signature = hmac.new(
webhook.secret_key.encode("utf-8"),
event_data_json.encode("utf-8"),
hashlib.sha256,
)
signature = hmac_signature.hexdigest()
headers["X-Plane-Signature"] = signature
action = { action = {
"POST": "create", "POST": "create",
"PATCH": "update", "PATCH": "update",
@ -116,6 +105,16 @@ def webhook_task(self, webhook, slug, event, event_data, action):
"data": event_data, "data": event_data,
} }
# Use HMAC for generating signature
if webhook.secret_key:
hmac_signature = hmac.new(
webhook.secret_key.encode("utf-8"),
json.dumps(payload, sort_keys=True).encode("utf-8"),
hashlib.sha256,
)
signature = hmac_signature.hexdigest()
headers["X-Plane-Signature"] = signature
# Send the webhook event # Send the webhook event
response = requests.post( response = requests.post(
webhook.url, webhook.url,

View File

@ -240,7 +240,7 @@ if AWS_S3_ENDPOINT_URL:
# JWT Auth Configuration # JWT Auth Configuration
SIMPLE_JWT = { SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=10080), "ACCESS_TOKEN_LIFETIME": timedelta(minutes=43200),
"REFRESH_TOKEN_LIFETIME": timedelta(days=43200), "REFRESH_TOKEN_LIFETIME": timedelta(days=43200),
"ROTATE_REFRESH_TOKENS": False, "ROTATE_REFRESH_TOKENS": False,
"BLACKLIST_AFTER_ROTATION": False, "BLACKLIST_AFTER_ROTATION": False,