Skip to content

Commit a8a16c8

Browse files
fix: replace IS_SELF_MANAGED with WEBHOOK_ALLOWED_IPS allowlist (#8884)
* fix: replace IS_SELF_MANAGED toggle with explicit WEBHOOK_ALLOWED_IPS allowlist Instead of blanket-allowing all private IPs on self-managed deployments, webhook URL validation now blocks all private/internal IPs by default and only permits specific networks listed in the WEBHOOK_ALLOWED_IPS env variable (comma-separated IPs/CIDRs). * fix: address PR review comments for webhook SSRF protection - Sanitize error messages to avoid leaking internal details to clients - Guard against TypeError with mixed IPv4/IPv6 allowlist networks - Re-validate webhook URL at send time to prevent DNS-rebinding - Add unit tests for mixed-version IP network allowlists
1 parent ac11c3e commit a8a16c8

5 files changed

Lines changed: 133 additions & 55 deletions

File tree

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

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

55
# Python imports
6-
import socket
7-
import ipaddress
6+
import logging
87
from urllib.parse import urlparse
98

109
# Third party imports
1110
from rest_framework import serializers
1211

12+
# Django imports
13+
from django.conf import settings
14+
1315
# Module imports
1416
from .base import DynamicBaseSerializer
1517
from plane.db.models import Webhook, WebhookLog
1618
from plane.db.models.webhook import validate_domain, validate_schema
19+
from plane.utils.ip_address import validate_url
20+
21+
logger = logging.getLogger(__name__)
1722

1823

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

22-
def create(self, validated_data):
23-
url = validated_data.get("url", None)
24-
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."})
29-
30-
# Resolve the hostname to IP addresses
27+
def _validate_webhook_url(self, url):
28+
"""Validate a webhook URL against SSRF and disallowed domain rules."""
3129
try:
32-
ip_addresses = socket.getaddrinfo(hostname, None)
33-
except socket.gaierror:
34-
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
35-
36-
if not ip_addresses:
37-
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
30+
validate_url(url, allowed_ips=settings.WEBHOOK_ALLOWED_IPS)
31+
except ValueError as e:
32+
logger.warning("Webhook URL validation failed for %s: %s", url, e)
33+
raise serializers.ValidationError({"url": "Invalid or disallowed webhook URL."})
3834

39-
for addr in ip_addresses:
40-
ip = ipaddress.ip_address(addr[4][0])
41-
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
42-
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
35+
hostname = (urlparse(url).hostname or "").rstrip(".").lower()
4336

44-
# Additional validation for multiple request domains and their subdomains
4537
request = self.context.get("request")
46-
disallowed_domains = ["plane.so"] # Add your disallowed domains here
38+
disallowed_domains = ["plane.so"]
4739
if request:
48-
request_host = request.get_host().split(":")[0] # Remove port if present
40+
request_host = request.get_host().split(":")[0].rstrip(".").lower()
4941
disallowed_domains.append(request_host)
5042

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

46+
def create(self, validated_data):
47+
url = validated_data.get("url", None)
48+
self._validate_webhook_url(url)
5549
return Webhook.objects.create(**validated_data)
5650

5751
def update(self, instance, validated_data):
5852
url = validated_data.get("url", None)
5953
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-
54+
self._validate_webhook_url(url)
9055
return super().update(instance, validated_data)
9156

9257
class Meta:

apps/api/plane/bgtasks/webhook_task.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
from plane.license.utils.instance_value import get_email_configuration
5353
from plane.utils.email import generate_plain_text_from_html
5454
from plane.utils.exception_logger import log_exception
55+
from plane.utils.ip_address import validate_url
5556
from plane.settings.mongo import MongoConnection
5657

5758

@@ -325,6 +326,9 @@ def webhook_send_task(
325326
return
326327

327328
try:
329+
# Re-validate the webhook URL at send time to prevent DNS-rebinding attacks
330+
validate_url(webhook.url, allowed_ips=settings.WEBHOOK_ALLOWED_IPS)
331+
328332
# Send the webhook event
329333
response = requests.post(webhook.url, headers=headers, json=payload, timeout=30)
330334

apps/api/plane/settings/common.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
"""Global Settings"""
66

77
# Python imports
8+
import ipaddress
9+
import logging
810
import os
911
from urllib.parse import urlparse
1012
from urllib.parse import urljoin
@@ -32,6 +34,21 @@
3234
# Self-hosted mode
3335
IS_SELF_MANAGED = True
3436

37+
# Webhook IP allowlist — comma-separated IPs or CIDR ranges that are allowed as
38+
# webhook targets even if they resolve to private networks.
39+
# Example: "10.0.0.0/8,192.168.1.0/24,172.16.0.5"
40+
_webhook_allowed_ips_raw = os.environ.get("WEBHOOK_ALLOWED_IPS", "")
41+
WEBHOOK_ALLOWED_IPS = []
42+
_logger = logging.getLogger("plane")
43+
for _cidr in _webhook_allowed_ips_raw.split(","):
44+
_cidr = _cidr.strip()
45+
if not _cidr:
46+
continue
47+
try:
48+
WEBHOOK_ALLOWED_IPS.append(ipaddress.ip_network(_cidr, strict=False))
49+
except ValueError:
50+
_logger.warning("WEBHOOK_ALLOWED_IPS: skipping invalid entry %r", _cidr)
51+
3552
# Allowed Hosts
3653
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",")
3754

apps/api/plane/tests/unit/bg_tasks/test_work_item_link_task.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
# SPDX-License-Identifier: AGPL-3.0-only
33
# See the LICENSE file for details.
44

5+
import ipaddress
6+
57
import pytest
68
from unittest.mock import patch, MagicMock
79
from plane.bgtasks.work_item_link_task import safe_get, validate_url_ip
10+
from plane.utils.ip_address import validate_url
811

912

1013
def _make_response(status_code=200, headers=None, is_redirect=False, content=b""):
@@ -43,6 +46,49 @@ def test_allows_public_ip(self):
4346
validate_url_ip("https://example.com") # Should not raise
4447

4548

49+
@pytest.mark.unit
50+
class TestValidateUrlAllowlist:
51+
"""Test validate_url allowlist permits specific private IPs."""
52+
53+
def test_allowlist_permits_private_ip(self):
54+
allowed = [ipaddress.ip_network("192.168.1.0/24")]
55+
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
56+
mock_dns.return_value = [(None, None, None, None, ("192.168.1.50", 0))]
57+
validate_url("http://example.com", allowed_ips=allowed) # Should not raise
58+
59+
def test_allowlist_does_not_permit_other_private_ip(self):
60+
allowed = [ipaddress.ip_network("192.168.1.0/24")]
61+
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
62+
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
63+
with pytest.raises(ValueError, match="private/internal"):
64+
validate_url("http://example.com", allowed_ips=allowed)
65+
66+
def test_allowlist_permits_loopback_when_explicitly_allowed(self):
67+
allowed = [ipaddress.ip_network("127.0.0.0/8")]
68+
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
69+
mock_dns.return_value = [(None, None, None, None, ("127.0.0.1", 0))]
70+
validate_url("http://example.com", allowed_ips=allowed) # Should not raise
71+
72+
def test_allowlist_permits_matching_ipv4_with_mixed_version_networks(self):
73+
allowed = [
74+
ipaddress.ip_network("2001:db8::/32"),
75+
ipaddress.ip_network("192.168.1.0/24"),
76+
]
77+
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
78+
mock_dns.return_value = [(None, None, None, None, ("192.168.1.50", 0))]
79+
validate_url("http://example.com", allowed_ips=allowed) # Should not raise
80+
81+
def test_allowlist_blocks_non_matching_ipv4_with_mixed_version_networks(self):
82+
allowed = [
83+
ipaddress.ip_network("2001:db8::/32"),
84+
ipaddress.ip_network("192.168.1.0/24"),
85+
]
86+
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
87+
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
88+
with pytest.raises(ValueError, match="private/internal"):
89+
validate_url("http://example.com", allowed_ips=allowed)
90+
91+
4692
@pytest.mark.unit
4793
class TestSafeGet:
4894
"""Test safe_get follows redirects safely and blocks SSRF."""

apps/api/plane/utils/ip_address.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,52 @@
22
# SPDX-License-Identifier: AGPL-3.0-only
33
# See the LICENSE file for details.
44

5+
# Python imports
6+
import ipaddress
7+
import socket
8+
from urllib.parse import urlparse
9+
10+
11+
def validate_url(url, allowed_ips=None):
12+
"""
13+
Validate that a URL doesn't resolve to a private/internal IP address (SSRF protection).
14+
15+
Args:
16+
url: The URL to validate.
17+
allowed_ips: Optional list of ipaddress.ip_network objects. IPs falling within
18+
these networks are permitted even if they are private/loopback/reserved.
19+
Typically sourced from the WEBHOOK_ALLOWED_IPS setting.
20+
21+
Raises:
22+
ValueError: If the URL is invalid or resolves to a blocked IP.
23+
"""
24+
parsed = urlparse(url)
25+
hostname = parsed.hostname
26+
27+
if not hostname:
28+
raise ValueError("Invalid URL: No hostname found")
29+
30+
if parsed.scheme not in ("http", "https"):
31+
raise ValueError("Invalid URL scheme. Only HTTP and HTTPS are allowed")
32+
33+
try:
34+
addr_info = socket.getaddrinfo(hostname, None)
35+
except socket.gaierror:
36+
raise ValueError("Hostname could not be resolved")
37+
38+
if not addr_info:
39+
raise ValueError("No IP addresses found for the hostname")
40+
41+
for addr in addr_info:
42+
ip = ipaddress.ip_address(addr[4][0])
43+
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
44+
if allowed_ips and any(
45+
network.version == ip.version and ip in network for network in allowed_ips
46+
):
47+
continue
48+
raise ValueError("Access to private/internal networks is not allowed")
49+
50+
551
def get_client_ip(request):
652
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
753
if x_forwarded_for:

0 commit comments

Comments
 (0)