From 761c999e0c2911132b23746a6c2c5225430cb216 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Fri, 15 May 2026 00:57:39 +0530 Subject: [PATCH 1/2] fix: add WEBHOOK_ALLOWED_HOSTS allowlist for internal webhook targets (#9078) * fix: add WEBHOOK_ALLOWED_HOSTS allowlist for internal webhook targets The IP-based allowlist alone isn't practical for containerised deployments where service IPs are dynamic. Adds a hostname-based bypass for trusted internal services (e.g. Silo via docker-compose / k8s service DNS) and makes the previously hardcoded ["plane.so"] domain blocklist configurable via WEBHOOK_DISALLOWED_DOMAINS. - validate_url accepts allowed_hosts (exact, case-insensitive match; skips DNS lookup for trusted names) - WebhookSerializer wires both settings through and lets allowlisted hosts bypass the disallowed-domain check - Exposes WEBHOOK_ALLOWED_HOSTS in aio/cli deployment env files * fix: default WEBHOOK_DISALLOWED_DOMAINS to empty for self-hosted * fix: pass WEBHOOK_ALLOWED_HOSTS to send-time webhook re-validation --- apps/api/plane/app/serializers/webhook.py | 15 ++++++- apps/api/plane/bgtasks/webhook_task.py | 6 ++- apps/api/plane/settings/common.py | 23 ++++++++++ .../unit/bg_tasks/test_work_item_link_task.py | 42 +++++++++++++++++++ apps/api/plane/utils/ip_address.py | 13 +++++- deployments/aio/community/variables.env | 9 ++++ deployments/cli/community/docker-compose.yml | 2 + deployments/cli/community/variables.env | 9 ++++ 8 files changed, 115 insertions(+), 4 deletions(-) diff --git a/apps/api/plane/app/serializers/webhook.py b/apps/api/plane/app/serializers/webhook.py index c5d0dd41e9..e08726f0d4 100644 --- a/apps/api/plane/app/serializers/webhook.py +++ b/apps/api/plane/app/serializers/webhook.py @@ -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, + ) 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) diff --git a/apps/api/plane/bgtasks/webhook_task.py b/apps/api/plane/bgtasks/webhook_task.py index 89d9875767..dd41575056 100644 --- a/apps/api/plane/bgtasks/webhook_task.py +++ b/apps/api/plane/bgtasks/webhook_task.py @@ -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) diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index d90ee10f89..165b3bd7ce 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -49,6 +49,29 @@ for _cidr in _webhook_allowed_ips_raw.split(","): 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(",") diff --git a/apps/api/plane/tests/unit/bg_tasks/test_work_item_link_task.py b/apps/api/plane/tests/unit/bg_tasks/test_work_item_link_task.py index 67c61c6cca..f2209e67e6 100644 --- a/apps/api/plane/tests/unit/bg_tasks/test_work_item_link_task.py +++ b/apps/api/plane/tests/unit/bg_tasks/test_work_item_link_task.py @@ -88,6 +88,48 @@ class TestValidateUrlAllowlist: 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: diff --git a/apps/api/plane/utils/ip_address.py b/apps/api/plane/utils/ip_address.py index 4102ad6f4c..ce1612a57a 100644 --- a/apps/api/plane/utils/ip_address.py +++ b/apps/api/plane/utils/ip_address.py @@ -8,7 +8,7 @@ import socket 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). @@ -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. @@ -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: diff --git a/deployments/aio/community/variables.env b/deployments/aio/community/variables.env index 99d93e3fda..53439a0d41 100644 --- a/deployments/aio/community/variables.env +++ b/deployments/aio/community/variables.env @@ -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= diff --git a/deployments/cli/community/docker-compose.yml b/deployments/cli/community/docker-compose.yml index 2ed44f0371..392ff72f6e 100644 --- a/deployments/cli/community/docker-compose.yml +++ b/deployments/cli/community/docker-compose.yml @@ -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: diff --git a/deployments/cli/community/variables.env b/deployments/cli/community/variables.env index 5a6c03f531..537d635832 100644 --- a/deployments/cli/community/variables.env +++ b/deployments/cli/community/variables.env @@ -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= From 1dabc632bf79ef860a8d6e6d3b48bfdea67875e5 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Fri, 15 May 2026 01:05:14 +0530 Subject: [PATCH 2/2] fix: pnpm path for Docker builds (#9079) Add $PNPM_HOME/bin to PATH so corepack-installed pnpm binaries are resolvable during Docker builds. --- apps/admin/Dockerfile.admin | 2 +- apps/live/Dockerfile.live | 2 +- apps/space/Dockerfile.space | 2 +- apps/web/Dockerfile.web | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/admin/Dockerfile.admin b/apps/admin/Dockerfile.admin index 19ad2c392a..3ee1d73bf9 100644 --- a/apps/admin/Dockerfile.admin +++ b/apps/admin/Dockerfile.admin @@ -4,7 +4,7 @@ WORKDIR /app ENV TURBO_TELEMETRY_DISABLED=1 ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" +ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH" ENV CI=1 RUN corepack enable pnpm diff --git a/apps/live/Dockerfile.live b/apps/live/Dockerfile.live index 801afca67f..864bd0d17e 100644 --- a/apps/live/Dockerfile.live +++ b/apps/live/Dockerfile.live @@ -3,7 +3,7 @@ FROM node:22-alpine AS base # Setup pnpm package manager with corepack and configure global bin directory for caching ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" +ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH" RUN corepack enable # ***************************************************************************** diff --git a/apps/space/Dockerfile.space b/apps/space/Dockerfile.space index 60d4a155aa..39a05176ae 100644 --- a/apps/space/Dockerfile.space +++ b/apps/space/Dockerfile.space @@ -4,7 +4,7 @@ WORKDIR /app ENV TURBO_TELEMETRY_DISABLED=1 ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" +ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH" ENV CI=1 RUN corepack enable pnpm diff --git a/apps/web/Dockerfile.web b/apps/web/Dockerfile.web index 38af19e74b..8da6b1e834 100644 --- a/apps/web/Dockerfile.web +++ b/apps/web/Dockerfile.web @@ -3,7 +3,7 @@ FROM node:22-alpine AS base # Setup pnpm package manager with corepack and configure global bin directory for caching ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" +ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH" RUN corepack enable # *****************************************************************************