Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions apps/api/plane/app/serializers/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,26 @@ class WebhookSerializer(DynamicBaseSerializer):
def _validate_webhook_url(self, url):
"""Validate a webhook URL against SSRF and disallowed domain rules."""
try:
validate_url(url, allowed_ips=settings.WEBHOOK_ALLOWED_IPS)
validate_url(
url,
allowed_ips=settings.WEBHOOK_ALLOWED_IPS,
allowed_hosts=settings.WEBHOOK_ALLOWED_HOSTS,
)
Comment on lines +30 to +34

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in aadc28a. The send-time re-validation in webhook_task.py now also passes allowed_hosts=settings.WEBHOOK_ALLOWED_HOSTS so allowlisted hostnames survive both creation and send.

except ValueError as e:
logger.warning("Webhook URL validation failed for %s: %s", url, e)
raise serializers.ValidationError({"url": "Invalid or disallowed webhook URL."})

hostname = (urlparse(url).hostname or "").rstrip(".").lower()

# Hosts explicitly trusted via WEBHOOK_ALLOWED_HOSTS bypass the
# disallowed-domain check — they're already trusted for SSRF, so
# the loop-back guard would only get in the way of legitimate
# sibling services that share a parent domain with Plane.
if hostname in settings.WEBHOOK_ALLOWED_HOSTS:
return

request = self.context.get("request")
disallowed_domains = ["plane.so"]
disallowed_domains = list(settings.WEBHOOK_DISALLOWED_DOMAINS)
if request:
request_host = request.get_host().split(":")[0].rstrip(".").lower()
disallowed_domains.append(request_host)
Expand Down
6 changes: 5 additions & 1 deletion apps/api/plane/bgtasks/webhook_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,11 @@ def webhook_send_task(

try:
# Re-validate the webhook URL at send time to prevent DNS-rebinding attacks
validate_url(webhook.url, allowed_ips=settings.WEBHOOK_ALLOWED_IPS)
validate_url(
webhook.url,
allowed_ips=settings.WEBHOOK_ALLOWED_IPS,
allowed_hosts=settings.WEBHOOK_ALLOWED_HOSTS,
)

# Send the webhook event
response = requests.post(webhook.url, headers=headers, json=payload, timeout=30)
Expand Down
23 changes: 23 additions & 0 deletions apps/api/plane/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,29 @@
except ValueError:
_logger.warning("WEBHOOK_ALLOWED_IPS: skipping invalid entry %r", _cidr)

# Webhook hostname allowlist — comma-separated hostnames that bypass the
# private-IP SSRF check. Useful for trusted internal services whose IPs are
# dynamic in containerised deployments (e.g. docker-compose service DNS,
# kubernetes service hostnames).
# Example: "silo,silo.namespace.svc.cluster.local,internal-api.lan"
_webhook_allowed_hosts_raw = os.environ.get("WEBHOOK_ALLOWED_HOSTS", "")
WEBHOOK_ALLOWED_HOSTS = [
_host.strip().rstrip(".").lower()
for _host in _webhook_allowed_hosts_raw.split(",")
if _host.strip()
]

# Webhook disallowed domains — comma-separated hostnames. Webhooks targeting
# these domains or any of their subdomains are rejected (the request host is
# always appended at validation time as a loop-back guard). Empty by default
# for self-hosted deployments; set to e.g. "plane.so" to block specific domains.
_webhook_disallowed_domains_raw = os.environ.get("WEBHOOK_DISALLOWED_DOMAINS", "")
WEBHOOK_DISALLOWED_DOMAINS = [
_d.strip().rstrip(".").lower()
for _d in _webhook_disallowed_domains_raw.split(",")
if _d.strip()
]

# Allowed Hosts
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",")

Expand Down
42 changes: 42 additions & 0 deletions apps/api/plane/tests/unit/bg_tasks/test_work_item_link_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,48 @@ def test_allowlist_blocks_non_matching_ipv4_with_mixed_version_networks(self):
with pytest.raises(ValueError, match="private/internal"):
validate_url("http://example.com", allowed_ips=allowed)

def test_allowed_hosts_bypasses_private_ip_check(self):
"""Hostnames in WEBHOOK_ALLOWED_HOSTS skip IP-based blocking — used for
trusted internal services (e.g. Silo) whose IPs are dynamic in
containerised deployments."""
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("172.18.0.5", 0))]
validate_url("http://silo:3000/hook", allowed_hosts=["silo"]) # Should not raise

def test_allowed_hosts_matches_case_insensitively(self):
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
validate_url(
"http://Silo.Namespace.Svc.Cluster.Local/x",
allowed_hosts=["silo.namespace.svc.cluster.local"],
) # Should not raise

def test_allowed_hosts_skips_dns_lookup(self):
"""When the hostname is explicitly trusted we shouldn't even resolve it —
protects against operators who allowlist a name that isn't resolvable
from the API container."""
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
validate_url("http://silo/hook", allowed_hosts=["silo"])
mock_dns.assert_not_called()

def test_allowed_hosts_requires_exact_match(self):
"""Subdomains of an allowed host must NOT bypass — a hostile
``attacker.silo.internal`` should still be blocked when only
``silo.internal`` is allowed."""
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("192.168.1.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url(
"http://attacker.silo.internal/x",
allowed_hosts=["silo.internal"],
)

def test_allowed_hosts_empty_does_not_bypass(self):
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url("http://silo/hook", allowed_hosts=[])


@pytest.mark.unit
class TestSafeGet:
Expand Down
13 changes: 12 additions & 1 deletion apps/api/plane/utils/ip_address.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from urllib.parse import urlparse


def validate_url(url, allowed_ips=None):
def validate_url(url, allowed_ips=None, allowed_hosts=None):
"""
Validate that a URL doesn't resolve to a private/internal IP address (SSRF protection).

Expand All @@ -17,6 +17,11 @@ def validate_url(url, allowed_ips=None):
allowed_ips: Optional list of ipaddress.ip_network objects. IPs falling within
these networks are permitted even if they are private/loopback/reserved.
Typically sourced from the WEBHOOK_ALLOWED_IPS setting.
allowed_hosts: Optional iterable of hostnames that bypass IP-based blocking
(exact, case-insensitive match against the URL hostname).
Typically sourced from the WEBHOOK_ALLOWED_HOSTS setting and
used for trusted internal services (e.g. Silo) whose IPs are
dynamic in containerised deployments.

Raises:
ValueError: If the URL is invalid or resolves to a blocked IP.
Expand All @@ -30,6 +35,12 @@ def validate_url(url, allowed_ips=None):
if parsed.scheme not in ("http", "https"):
raise ValueError("Invalid URL scheme. Only HTTP and HTTPS are allowed")

normalized_host = hostname.rstrip(".").lower()
if allowed_hosts and normalized_host in {
(h or "").rstrip(".").lower() for h in allowed_hosts if h
}:
return

try:
addr_info = socket.getaddrinfo(hostname, None)
except socket.gaierror:
Expand Down
9 changes: 9 additions & 0 deletions deployments/aio/community/variables.env
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,12 @@ API_KEY_RATE_LIMIT=60/minute

# Live Server Secret Key
LIVE_SERVER_SECRET_KEY=htbqvBJAgpm9bzvf3r4urJer0ENReatceh

# Webhook IP allowlist — comma-separated IPs or CIDR ranges allowed as webhook targets
# even if they resolve to private networks (e.g. "10.0.0.0/8,192.168.1.0/24,172.16.0.5")
WEBHOOK_ALLOWED_IPS=

# Webhook hostname allowlist — comma-separated hostnames that bypass the private-IP
# SSRF check. Useful for trusted internal services whose container/service IPs are
# dynamic (e.g. "silo,silo.namespace.svc.cluster.local")
WEBHOOK_ALLOWED_HOSTS=
2 changes: 2 additions & 0 deletions deployments/cli/community/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ x-app-env: &app-env
API_KEY_RATE_LIMIT: ${API_KEY_RATE_LIMIT:-60/minute}
MINIO_ENDPOINT_SSL: ${MINIO_ENDPOINT_SSL:-0}
LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY:-2FiJk1U2aiVPEQtzLehYGlTSnTnrs7LW}
WEBHOOK_ALLOWED_IPS: ${WEBHOOK_ALLOWED_IPS:-}
WEBHOOK_ALLOWED_HOSTS: ${WEBHOOK_ALLOWED_HOSTS:-}

services:
web:
Expand Down
9 changes: 9 additions & 0 deletions deployments/cli/community/variables.env
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,12 @@ API_KEY_RATE_LIMIT=60/minute
# Live server environment variables
# WARNING: You must set a secure value for LIVE_SERVER_SECRET_KEY in production environments.
LIVE_SERVER_SECRET_KEY=

# Webhook IP allowlist — comma-separated IPs or CIDR ranges allowed as webhook targets
# even if they resolve to private networks (e.g. "10.0.0.0/8,192.168.1.0/24,172.16.0.5")
WEBHOOK_ALLOWED_IPS=

# Webhook hostname allowlist — comma-separated hostnames that bypass the private-IP
# SSRF check. Useful for trusted internal services whose container/service IPs are
# dynamic (e.g. "silo,silo.namespace.svc.cluster.local")
WEBHOOK_ALLOWED_HOSTS=