Skip to content

Commit 7fd8e33

Browse files
Merge branch 'canary' of github.com:makeplane/plane into preview
2 parents 4225bc5 + 1dabc63 commit 7fd8e33

12 files changed

Lines changed: 119 additions & 8 deletions

File tree

apps/admin/Dockerfile.admin

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ WORKDIR /app
44

55
ENV TURBO_TELEMETRY_DISABLED=1
66
ENV PNPM_HOME="/pnpm"
7-
ENV PATH="$PNPM_HOME:$PATH"
7+
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
88
ENV CI=1
99

1010
RUN corepack enable pnpm

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,26 @@ class WebhookSerializer(DynamicBaseSerializer):
2727
def _validate_webhook_url(self, url):
2828
"""Validate a webhook URL against SSRF and disallowed domain rules."""
2929
try:
30-
validate_url(url, allowed_ips=settings.WEBHOOK_ALLOWED_IPS)
30+
validate_url(
31+
url,
32+
allowed_ips=settings.WEBHOOK_ALLOWED_IPS,
33+
allowed_hosts=settings.WEBHOOK_ALLOWED_HOSTS,
34+
)
3135
except ValueError as e:
3236
logger.warning("Webhook URL validation failed for %s: %s", url, e)
3337
raise serializers.ValidationError({"url": "Invalid or disallowed webhook URL."})
3438

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

41+
# Hosts explicitly trusted via WEBHOOK_ALLOWED_HOSTS bypass the
42+
# disallowed-domain check — they're already trusted for SSRF, so
43+
# the loop-back guard would only get in the way of legitimate
44+
# sibling services that share a parent domain with Plane.
45+
if hostname in settings.WEBHOOK_ALLOWED_HOSTS:
46+
return
47+
3748
request = self.context.get("request")
38-
disallowed_domains = ["plane.so"]
49+
disallowed_domains = list(settings.WEBHOOK_DISALLOWED_DOMAINS)
3950
if request:
4051
request_host = request.get_host().split(":")[0].rstrip(".").lower()
4152
disallowed_domains.append(request_host)

apps/api/plane/bgtasks/webhook_task.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,11 @@ def webhook_send_task(
327327

328328
try:
329329
# Re-validate the webhook URL at send time to prevent DNS-rebinding attacks
330-
validate_url(webhook.url, allowed_ips=settings.WEBHOOK_ALLOWED_IPS)
330+
validate_url(
331+
webhook.url,
332+
allowed_ips=settings.WEBHOOK_ALLOWED_IPS,
333+
allowed_hosts=settings.WEBHOOK_ALLOWED_HOSTS,
334+
)
331335

332336
# Send the webhook event
333337
response = requests.post(webhook.url, headers=headers, json=payload, timeout=30)

apps/api/plane/settings/common.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,29 @@
4949
except ValueError:
5050
_logger.warning("WEBHOOK_ALLOWED_IPS: skipping invalid entry %r", _cidr)
5151

52+
# Webhook hostname allowlist — comma-separated hostnames that bypass the
53+
# private-IP SSRF check. Useful for trusted internal services whose IPs are
54+
# dynamic in containerised deployments (e.g. docker-compose service DNS,
55+
# kubernetes service hostnames).
56+
# Example: "silo,silo.namespace.svc.cluster.local,internal-api.lan"
57+
_webhook_allowed_hosts_raw = os.environ.get("WEBHOOK_ALLOWED_HOSTS", "")
58+
WEBHOOK_ALLOWED_HOSTS = [
59+
_host.strip().rstrip(".").lower()
60+
for _host in _webhook_allowed_hosts_raw.split(",")
61+
if _host.strip()
62+
]
63+
64+
# Webhook disallowed domains — comma-separated hostnames. Webhooks targeting
65+
# these domains or any of their subdomains are rejected (the request host is
66+
# always appended at validation time as a loop-back guard). Empty by default
67+
# for self-hosted deployments; set to e.g. "plane.so" to block specific domains.
68+
_webhook_disallowed_domains_raw = os.environ.get("WEBHOOK_DISALLOWED_DOMAINS", "")
69+
WEBHOOK_DISALLOWED_DOMAINS = [
70+
_d.strip().rstrip(".").lower()
71+
for _d in _webhook_disallowed_domains_raw.split(",")
72+
if _d.strip()
73+
]
74+
5275
# Allowed Hosts
5376
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",")
5477

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,48 @@ def test_allowlist_blocks_non_matching_ipv4_with_mixed_version_networks(self):
8888
with pytest.raises(ValueError, match="private/internal"):
8989
validate_url("http://example.com", allowed_ips=allowed)
9090

91+
def test_allowed_hosts_bypasses_private_ip_check(self):
92+
"""Hostnames in WEBHOOK_ALLOWED_HOSTS skip IP-based blocking — used for
93+
trusted internal services (e.g. Silo) whose IPs are dynamic in
94+
containerised deployments."""
95+
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
96+
mock_dns.return_value = [(None, None, None, None, ("172.18.0.5", 0))]
97+
validate_url("http://silo:3000/hook", allowed_hosts=["silo"]) # Should not raise
98+
99+
def test_allowed_hosts_matches_case_insensitively(self):
100+
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
101+
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
102+
validate_url(
103+
"http://Silo.Namespace.Svc.Cluster.Local/x",
104+
allowed_hosts=["silo.namespace.svc.cluster.local"],
105+
) # Should not raise
106+
107+
def test_allowed_hosts_skips_dns_lookup(self):
108+
"""When the hostname is explicitly trusted we shouldn't even resolve it —
109+
protects against operators who allowlist a name that isn't resolvable
110+
from the API container."""
111+
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
112+
validate_url("http://silo/hook", allowed_hosts=["silo"])
113+
mock_dns.assert_not_called()
114+
115+
def test_allowed_hosts_requires_exact_match(self):
116+
"""Subdomains of an allowed host must NOT bypass — a hostile
117+
``attacker.silo.internal`` should still be blocked when only
118+
``silo.internal`` is allowed."""
119+
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
120+
mock_dns.return_value = [(None, None, None, None, ("192.168.1.1", 0))]
121+
with pytest.raises(ValueError, match="private/internal"):
122+
validate_url(
123+
"http://attacker.silo.internal/x",
124+
allowed_hosts=["silo.internal"],
125+
)
126+
127+
def test_allowed_hosts_empty_does_not_bypass(self):
128+
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
129+
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
130+
with pytest.raises(ValueError, match="private/internal"):
131+
validate_url("http://silo/hook", allowed_hosts=[])
132+
91133

92134
@pytest.mark.unit
93135
class TestSafeGet:

apps/api/plane/utils/ip_address.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from urllib.parse import urlparse
99

1010

11-
def validate_url(url, allowed_ips=None):
11+
def validate_url(url, allowed_ips=None, allowed_hosts=None):
1212
"""
1313
Validate that a URL doesn't resolve to a private/internal IP address (SSRF protection).
1414
@@ -17,6 +17,11 @@ def validate_url(url, allowed_ips=None):
1717
allowed_ips: Optional list of ipaddress.ip_network objects. IPs falling within
1818
these networks are permitted even if they are private/loopback/reserved.
1919
Typically sourced from the WEBHOOK_ALLOWED_IPS setting.
20+
allowed_hosts: Optional iterable of hostnames that bypass IP-based blocking
21+
(exact, case-insensitive match against the URL hostname).
22+
Typically sourced from the WEBHOOK_ALLOWED_HOSTS setting and
23+
used for trusted internal services (e.g. Silo) whose IPs are
24+
dynamic in containerised deployments.
2025
2126
Raises:
2227
ValueError: If the URL is invalid or resolves to a blocked IP.
@@ -30,6 +35,12 @@ def validate_url(url, allowed_ips=None):
3035
if parsed.scheme not in ("http", "https"):
3136
raise ValueError("Invalid URL scheme. Only HTTP and HTTPS are allowed")
3237

38+
normalized_host = hostname.rstrip(".").lower()
39+
if allowed_hosts and normalized_host in {
40+
(h or "").rstrip(".").lower() for h in allowed_hosts if h
41+
}:
42+
return
43+
3344
try:
3445
addr_info = socket.getaddrinfo(hostname, None)
3546
except socket.gaierror:

apps/live/Dockerfile.live

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ FROM node:22-alpine AS base
33

44
# Setup pnpm package manager with corepack and configure global bin directory for caching
55
ENV PNPM_HOME="/pnpm"
6-
ENV PATH="$PNPM_HOME:$PATH"
6+
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
77
RUN corepack enable
88

99
# *****************************************************************************

apps/space/Dockerfile.space

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ WORKDIR /app
44

55
ENV TURBO_TELEMETRY_DISABLED=1
66
ENV PNPM_HOME="/pnpm"
7-
ENV PATH="$PNPM_HOME:$PATH"
7+
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
88
ENV CI=1
99

1010
RUN corepack enable pnpm

apps/web/Dockerfile.web

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ FROM node:22-alpine AS base
33

44
# Setup pnpm package manager with corepack and configure global bin directory for caching
55
ENV PNPM_HOME="/pnpm"
6-
ENV PATH="$PNPM_HOME:$PATH"
6+
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
77
RUN corepack enable
88

99
# *****************************************************************************

deployments/aio/community/variables.env

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,12 @@ API_KEY_RATE_LIMIT=60/minute
5151

5252
# Live Server Secret Key
5353
LIVE_SERVER_SECRET_KEY=htbqvBJAgpm9bzvf3r4urJer0ENReatceh
54+
55+
# Webhook IP allowlist — comma-separated IPs or CIDR ranges allowed as webhook targets
56+
# even if they resolve to private networks (e.g. "10.0.0.0/8,192.168.1.0/24,172.16.0.5")
57+
WEBHOOK_ALLOWED_IPS=
58+
59+
# Webhook hostname allowlist — comma-separated hostnames that bypass the private-IP
60+
# SSRF check. Useful for trusted internal services whose container/service IPs are
61+
# dynamic (e.g. "silo,silo.namespace.svc.cluster.local")
62+
WEBHOOK_ALLOWED_HOSTS=

0 commit comments

Comments
 (0)