Skip to content

Commit 2e3799e

Browse files
feat: allow private/loopback webhook URLs for self-hosted instances
Add PLANE_ALLOW_PRIVATE_WEBHOOKS environment variable that bypasses IP validation for webhook URLs when set to '1', 'true', or 'yes'. Disabled by default. Self-hosted Plane instances commonly need webhooks pointing to local services (notification relays, CI runners, internal APIs) but the SSRF protection blocks all private/loopback/reserved/link-local IPs. Changes: - Gated private-IP bypass via env var (default: disabled) - Startup log warning when flag is enabled - Extracted shared URL validation into _validate_webhook_url() helper - Zero impact on existing behavior when not set Refs: #5248
1 parent 5747dc6 commit 2e3799e

1 file changed

Lines changed: 47 additions & 55 deletions

File tree

apps/api/plane/app/serializers/webhook.py

Lines changed: 47 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,90 +3,82 @@
33
# See the LICENSE file for details.
44

55
# Python imports
6+
import logging
7+
import os
68
import socket
79
import ipaddress
810
from urllib.parse import urlparse
911

1012
# Third party imports
1113
from rest_framework import serializers
1214

15+
# Allow private/loopback webhook URLs for self-hosted instances.
16+
# When enabled, SSRF protection (private/loopback/reserved/link-local IP
17+
# blocking) is bypassed for webhook URLs. Only enable this in trusted
18+
# self-hosted environments where webhooks target local services.
19+
ALLOW_PRIVATE_WEBHOOKS = os.environ.get("PLANE_ALLOW_PRIVATE_WEBHOOKS", "0").lower() in ("1", "true", "yes")
20+
21+
if ALLOW_PRIVATE_WEBHOOKS:
22+
logging.getLogger(__name__).warning(
23+
"PLANE_ALLOW_PRIVATE_WEBHOOKS is enabled — webhooks can target "
24+
"private/internal IPs. Only enable this in trusted self-hosted "
25+
"environments."
26+
)
27+
1328
# Module imports
1429
from .base import DynamicBaseSerializer
1530
from plane.db.models import Webhook, WebhookLog
1631
from plane.db.models.webhook import validate_domain, validate_schema
1732

1833

19-
class WebhookSerializer(DynamicBaseSerializer):
20-
url = serializers.URLField(validators=[validate_schema, validate_domain])
21-
22-
def create(self, validated_data):
23-
url = validated_data.get("url", None)
34+
def _validate_webhook_url(url, request):
35+
"""Validate a webhook URL: resolve hostname, check IP restrictions, and
36+
block disallowed domains.
2437
25-
# Extract the hostname from the URL
26-
hostname = urlparse(url).hostname
27-
if not hostname:
28-
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
38+
Raises ``serializers.ValidationError`` on failure.
39+
"""
40+
hostname = urlparse(url).hostname
41+
if not hostname:
42+
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
2943

30-
# Resolve the hostname to IP addresses
31-
try:
32-
ip_addresses = socket.getaddrinfo(hostname, None)
33-
except socket.gaierror:
34-
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
44+
# Resolve the hostname to IP addresses
45+
try:
46+
ip_addresses = socket.getaddrinfo(hostname, None)
47+
except socket.gaierror:
48+
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
3549

36-
if not ip_addresses:
37-
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
50+
if not ip_addresses:
51+
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
3852

53+
# Block private/loopback/reserved/link-local IPs unless explicitly allowed
54+
if not ALLOW_PRIVATE_WEBHOOKS:
3955
for addr in ip_addresses:
4056
ip = ipaddress.ip_address(addr[4][0])
4157
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
4258
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
4359

44-
# Additional validation for multiple request domains and their subdomains
45-
request = self.context.get("request")
46-
disallowed_domains = ["plane.so"] # Add your disallowed domains here
47-
if request:
48-
request_host = request.get_host().split(":")[0] # Remove port if present
49-
disallowed_domains.append(request_host)
60+
# Additional validation for disallowed domains and their subdomains
61+
disallowed_domains = ["plane.so"]
62+
if request:
63+
request_host = request.get_host().split(":")[0] # Remove port if present
64+
disallowed_domains.append(request_host)
5065

51-
# Check if hostname is a subdomain or exact match of any disallowed domain
52-
if any(hostname == domain or hostname.endswith("." + domain) for domain in disallowed_domains):
53-
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
66+
if any(hostname == domain or hostname.endswith("." + domain) for domain in disallowed_domains):
67+
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
5468

69+
70+
class WebhookSerializer(DynamicBaseSerializer):
71+
url = serializers.URLField(validators=[validate_schema, validate_domain])
72+
73+
def create(self, validated_data):
74+
url = validated_data.get("url", None)
75+
_validate_webhook_url(url, self.context.get("request"))
5576
return Webhook.objects.create(**validated_data)
5677

5778
def update(self, instance, validated_data):
5879
url = validated_data.get("url", None)
5980
if url:
60-
# Extract the hostname from the URL
61-
hostname = urlparse(url).hostname
62-
if not hostname:
63-
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
64-
65-
# Resolve the hostname to IP addresses
66-
try:
67-
ip_addresses = socket.getaddrinfo(hostname, None)
68-
except socket.gaierror:
69-
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
70-
71-
if not ip_addresses:
72-
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
73-
74-
for addr in ip_addresses:
75-
ip = ipaddress.ip_address(addr[4][0])
76-
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
77-
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
78-
79-
# Additional validation for multiple request domains and their subdomains
80-
request = self.context.get("request")
81-
disallowed_domains = ["plane.so"] # Add your disallowed domains here
82-
if request:
83-
request_host = request.get_host().split(":")[0] # Remove port if present
84-
disallowed_domains.append(request_host)
85-
86-
# Check if hostname is a subdomain or exact match of any disallowed domain
87-
if any(hostname == domain or hostname.endswith("." + domain) for domain in disallowed_domains):
88-
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
89-
81+
_validate_webhook_url(url, self.context.get("request"))
9082
return super().update(instance, validated_data)
9183

9284
class Meta:

0 commit comments

Comments
 (0)