Skip to content

Commit

Permalink
chore: api webhooks validation (#2928)
Browse files Browse the repository at this point in the history
* chore: api webhooks update

* chore: webhooks signature validation
  • Loading branch information
NarayanBavisetti authored Nov 29, 2023
1 parent cdc4fd2 commit 6a4f521
Show file tree
Hide file tree
Showing 7 changed files with 63 additions and 32 deletions.
1 change: 0 additions & 1 deletion apiserver/plane/api/serializers/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ def validate(self, data):
raise serializers.ValidationError("Start date cannot exceed target date")

if data.get("members", []):
print(data.get("members"))
data["members"] = ProjectMember.objects.filter(
project_id=self.context.get("project_id"),
member_id__in=data["members"],
Expand Down
2 changes: 1 addition & 1 deletion apiserver/plane/api/urls/inbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
name="inbox-issue",
),
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(),
name="inbox-issue",
),
Expand Down
24 changes: 12 additions & 12 deletions apiserver/plane/api/views/inbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ def get_queryset(self):
.order_by(self.kwargs.get("order_by", "-created_at"))
)

def get(self, request, slug, project_id, pk=None):
if pk:
inbox_issue_queryset = self.get_queryset().get(pk=pk)
def get(self, request, slug, project_id, issue_id=None):
if issue_id:
inbox_issue_queryset = self.get_queryset().get(issue_id=issue_id)
inbox_issue_data = InboxIssueSerializer(
inbox_issue_queryset,
fields=self.fields,
Expand Down Expand Up @@ -163,7 +163,7 @@ def post(self, request, slug, project_id):
serializer = InboxIssueSerializer(inbox_issue)
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(
workspace__slug=slug, project_id=project_id
).first()
Expand All @@ -184,7 +184,7 @@ def patch(self, request, slug, project_id, pk):

# Get the inbox issue
inbox_issue = InboxIssue.objects.get(
pk=pk,
issue_id=issue_id,
workspace__slug=slug,
project_id=project_id,
inbox_id=inbox.id,
Expand Down Expand Up @@ -212,7 +212,7 @@ def patch(self, request, slug, project_id, pk):

if bool(issue_data):
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
if project_member.role <= 10:
Expand All @@ -236,7 +236,7 @@ def patch(self, request, slug, project_id, pk):
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(current_instance).data,
Expand All @@ -261,7 +261,7 @@ def patch(self, request, slug, project_id, pk):
# Update the issue state if the issue is rejected or marked as duplicate
if serializer.data["status"] in [-1, 2]:
issue = Issue.objects.get(
pk=inbox_issue.issue_id,
pk=issue_id,
workspace__slug=slug,
project_id=project_id,
)
Expand All @@ -275,7 +275,7 @@ def patch(self, request, slug, project_id, pk):
# Update the issue state if it is accepted
if serializer.data["status"] in [1]:
issue = Issue.objects.get(
pk=inbox_issue.issue_id,
pk=issue_id,
workspace__slug=slug,
project_id=project_id,
)
Expand All @@ -297,7 +297,7 @@ def patch(self, request, slug, project_id, pk):
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(
workspace__slug=slug, project_id=project_id
).first()
Expand All @@ -318,7 +318,7 @@ def delete(self, request, slug, project_id, pk):

# Get the inbox issue
inbox_issue = InboxIssue.objects.get(
pk=pk,
issue_id=issue_id,
workspace__slug=slug,
project_id=project_id,
inbox_id=inbox.id,
Expand All @@ -345,7 +345,7 @@ def delete(self, request, slug, project_id, pk):
if inbox_issue.status in [-2, -1, 0, 2]:
# Delete the issue also
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()

inbox_issue.delete()
Expand Down
44 changes: 39 additions & 5 deletions apiserver/plane/app/serializers/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@

class WebhookSerializer(DynamicBaseSerializer):
url = serializers.URLField(validators=[validate_schema, validate_domain])

def validate(self, data):
url = data.get("url", None)
def create(self, validated_data):
url = validated_data.get("url", None)

# Extract the hostname from the URL
hostname = urlparse(url).hostname
Expand Down Expand Up @@ -48,8 +48,42 @@ def validate(self, data):
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

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:
model = Webhook
Expand Down
1 change: 0 additions & 1 deletion apiserver/plane/bgtasks/event_tracking_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

@shared_task
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:
posthog = Posthog(settings.POSTHOG_API_KEY, host=settings.POSTHOG_HOST)
posthog.capture(
Expand Down
21 changes: 10 additions & 11 deletions apiserver/plane/bgtasks/webhook_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,17 +90,6 @@ def webhook_task(self, webhook, slug, event, event_data, action):
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 = {
"POST": "create",
"PATCH": "update",
Expand All @@ -116,6 +105,16 @@ def webhook_task(self, webhook, slug, event, event_data, action):
"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
response = requests.post(
webhook.url,
Expand Down
2 changes: 1 addition & 1 deletion apiserver/plane/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@

# JWT Auth Configuration
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=10080),
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=43200),
"REFRESH_TOKEN_LIFETIME": timedelta(days=43200),
"ROTATE_REFRESH_TOKENS": False,
"BLACKLIST_AFTER_ROTATION": False,
Expand Down

0 comments on commit 6a4f521

Please sign in to comment.